Skip to main content

regit_curves/
types.rs

1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! Core temporal and convention types.
5//!
6//! This module defines the calendar and convention primitives on which every
7//! curve and instrument is built:
8//!
9//! - [`Date`] — a proleptic-Gregorian calendar date stored as an `i32` day
10//!   serial counted from the epoch `1970-01-01`. All conversions are exact
11//!   integer arithmetic — no floats are used to compute year/month/day.
12//! - [`Tenor`] / [`TenorUnit`] — a length of time with a unit.
13//! - [`Daycount`] — the set of ISDA / ICMA day-count conventions used to
14//!   translate a date range into a year fraction.
15//! - [`Compounding`] — the mapping between a discount factor and a zero rate.
16//! - [`Frequency`] — a payment frequency for swap legs.
17//! - [`BusinessDayConvention`] — a documentation-only enum naming the
18//!   business-day-adjustment conventions (calendar-aware adjustment is
19//!   out-of-scope for the crate; the caller composes with its own calendar).
20//!
21//! # Calendar
22//!
23//! The proleptic-Gregorian calendar is the Gregorian calendar extended
24//! backwards through the pre-1582 era. Historical dates before the Gregorian
25//! reform are therefore not the dates that would have been recorded at the
26//! time, but the calendar is exact, monotonic, and unambiguous for every
27//! financial use case (dates are typically post-1900).
28//!
29//! # Day-counts
30//!
31//! Each variant of [`Daycount`] implements the rule from its primary source.
32//! See [`Daycount::year_fraction`] for the formulas and citations.
33//!
34//! # References
35//!
36//! - Hinnant, H., *chrono-Compatible Low-Level Date Algorithms*,
37//!   <https://howardhinnant.github.io/date_algorithms.html>. The
38//!   `days_from_civil` and `civil_from_days` integer formulae used here.
39//! - ISDA, *2006 ISDA Definitions*, §4.16. Day-count conventions.
40//! - ICMA, *Rule 251*. The Actual/Actual (ICMA) convention.
41
42use crate::errors::TypeError;
43
44// ─── Date ────────────────────────────────────────────────────────────────────
45
46/// A calendar date, stored as a signed day-serial since `1970-01-01`.
47///
48/// The internal representation is `i32`, days since the proleptic-Gregorian
49/// epoch `1970-01-01`. The range is roughly ±5.8 million years — far beyond
50/// any financial use. All arithmetic is integer; no floats are used in
51/// calendar conversions.
52///
53/// # Examples
54///
55/// ```
56/// use regit_curves::types::Date;
57///
58/// let d = Date::from_ymd(2024, 6, 15).unwrap();
59/// assert_eq!(d.year(), 2024);
60/// assert_eq!(d.month(), 6);
61/// assert_eq!(d.day(), 15);
62/// ```
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
64pub struct Date(i32);
65
66impl Date {
67    /// Constructs a `Date` from proleptic-Gregorian year/month/day.
68    ///
69    /// Validation rejects months outside `1..=12`, days outside `1..=31`,
70    /// and days that do not exist in the given month (e.g. February 30, or
71    /// February 29 in a non-leap year).
72    ///
73    /// The integer formula is Hinnant's `days_from_civil`
74    /// (<https://howardhinnant.github.io/date_algorithms.html>) with the
75    /// epoch shifted to `1970-01-01`.
76    ///
77    /// # Errors
78    ///
79    /// - [`TypeError::InvalidDate`] if `(year, month, day)` is not a real
80    ///   proleptic-Gregorian date.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use regit_curves::types::Date;
86    ///
87    /// let leap = Date::from_ymd(2000, 2, 29).unwrap();
88    /// assert_eq!(leap.day(), 29);
89    /// assert!(Date::from_ymd(2023, 2, 29).is_err());
90    /// ```
91    pub fn from_ymd(year: i32, month: u32, day: u32) -> Result<Self, TypeError> {
92        if !(1..=12).contains(&month) {
93            return Err(TypeError::InvalidDate { year, month, day });
94        }
95        if day == 0 || day > days_in_month(year, month) {
96            return Err(TypeError::InvalidDate { year, month, day });
97        }
98        Ok(Self(days_from_civil(year, month, day)))
99    }
100
101    /// Constructs a `Date` from its day-serial (days since `1970-01-01`).
102    ///
103    /// No validation is performed (every `i32` is a valid serial).
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use regit_curves::types::Date;
109    ///
110    /// // 1970-01-01 has serial 0.
111    /// let d = Date::from_serial(0);
112    /// assert_eq!(d.year(), 1970);
113    /// assert_eq!(d.month(), 1);
114    /// assert_eq!(d.day(), 1);
115    /// ```
116    #[must_use]
117    #[inline]
118    pub const fn from_serial(days: i32) -> Self {
119        Self(days)
120    }
121
122    /// Returns the day-serial since `1970-01-01`.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use regit_curves::types::Date;
128    ///
129    /// assert_eq!(Date::from_serial(0).serial(), 0);
130    /// assert_eq!(Date::from_ymd(1970, 1, 2).unwrap().serial(), 1);
131    /// ```
132    #[must_use]
133    #[inline]
134    pub const fn serial(self) -> i32 {
135        self.0
136    }
137
138    /// Returns the proleptic-Gregorian year.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use regit_curves::types::Date;
144    ///
145    /// assert_eq!(Date::from_ymd(2024, 6, 15).unwrap().year(), 2024);
146    /// ```
147    #[must_use]
148    pub fn year(self) -> i32 {
149        civil_from_days(self.0).0
150    }
151
152    /// Returns the month of the year (`1..=12`).
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use regit_curves::types::Date;
158    ///
159    /// assert_eq!(Date::from_ymd(2024, 6, 15).unwrap().month(), 6);
160    /// ```
161    #[must_use]
162    pub fn month(self) -> u32 {
163        civil_from_days(self.0).1
164    }
165
166    /// Returns the day of the month (`1..=31`).
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use regit_curves::types::Date;
172    ///
173    /// assert_eq!(Date::from_ymd(2024, 6, 15).unwrap().day(), 15);
174    /// ```
175    #[must_use]
176    pub fn day(self) -> u32 {
177        civil_from_days(self.0).2
178    }
179
180    /// Adds `days` calendar days, returning the resulting date.
181    ///
182    /// # Examples
183    ///
184    /// ```
185    /// use regit_curves::types::Date;
186    ///
187    /// let d = Date::from_ymd(2024, 6, 15).unwrap();
188    /// let next = d.add_days(1);
189    /// assert_eq!(next.day(), 16);
190    /// ```
191    #[must_use]
192    #[inline]
193    pub const fn add_days(self, days: i32) -> Self {
194        Self(self.0.wrapping_add(days))
195    }
196
197    /// Returns the signed calendar-day difference `other - self` (i.e. number
198    /// of days from `self` to `other`).
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use regit_curves::types::Date;
204    ///
205    /// let a = Date::from_ymd(2024, 1, 1).unwrap();
206    /// let b = Date::from_ymd(2024, 12, 31).unwrap();
207    /// // 2024 is a leap year — 366 days, last day is at offset 365.
208    /// assert_eq!(a.days_between(b), 365);
209    /// ```
210    #[must_use]
211    #[inline]
212    pub const fn days_between(self, other: Self) -> i32 {
213        other.0.wrapping_sub(self.0)
214    }
215}
216
217/// Returns `true` if `year` is a leap year in the proleptic-Gregorian
218/// calendar (divisible by 4 but not by 100, unless also by 400).
219#[inline]
220const fn is_leap_year(year: i32) -> bool {
221    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
222}
223
224/// Returns the number of days in the given (year, month).
225#[inline]
226fn days_in_month(year: i32, month: u32) -> u32 {
227    match month {
228        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
229        4 | 6 | 9 | 11 => 30,
230        2 => {
231            if is_leap_year(year) {
232                29
233            } else {
234                28
235            }
236        }
237        _ => 0,
238    }
239}
240
241/// Hinnant's `days_from_civil`: converts a proleptic-Gregorian (y, m, d) to
242/// the day-serial relative to `1970-01-01`.
243///
244/// Pre: `1 <= m <= 12`, `1 <= d <= days_in_month(y, m)`.
245fn days_from_civil(y: i32, m: u32, d: u32) -> i32 {
246    let y = if m <= 2 { y - 1 } else { y };
247    let era = if y >= 0 { y } else { y - 399 } / 400;
248    // Year-of-era is in [0, 399] by construction of `era`.
249    let yoe = u32::try_from(y - era * 400).unwrap_or(0);
250    // Day-of-year with March = 0, in [0, 365].
251    let mp = if m > 2 { m - 3 } else { m + 9 };
252    let doy = (153 * mp + 2) / 5 + d - 1;
253    // Day-of-era, in [0, 146096].
254    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
255    // doe fits in u32, doe < 146097 so it fits in i32 safely.
256    let doe_i = i32::try_from(doe).unwrap_or(i32::MAX);
257    era * 146_097 + doe_i - 719_468
258}
259
260/// Hinnant's `civil_from_days`: converts a day-serial relative to
261/// `1970-01-01` into a proleptic-Gregorian (y, m, d).
262fn civil_from_days(z: i32) -> (i32, u32, u32) {
263    let z = z + 719_468;
264    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
265    // Day-of-era is in [0, 146096] by construction of `era`.
266    let doe = u32::try_from(z - era * 146_097).unwrap_or(0);
267    // Year-of-era, in [0, 399].
268    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
269    let y_partial = i32::try_from(yoe).unwrap_or(i32::MAX) + era * 400;
270    // Day-of-year (March = 0), in [0, 365].
271    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
272    // Month-prime, in [0, 11].
273    let mp = (5 * doy + 2) / 153;
274    let d = doy - (153 * mp + 2) / 5 + 1;
275    let m = if mp < 10 { mp + 3 } else { mp - 9 };
276    let y = if m <= 2 { y_partial + 1 } else { y_partial };
277    (y, m, d)
278}
279
280// ─── Tenor ───────────────────────────────────────────────────────────────────
281
282/// The unit of a [`Tenor`].
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
284pub enum TenorUnit {
285    /// Days.
286    Days,
287    /// Weeks.
288    Weeks,
289    /// Months.
290    Months,
291    /// Years.
292    Years,
293}
294
295/// A tenor — a length of time expressed in a unit.
296///
297/// `Tenor` is a fully literal value (`count`, `unit`); it does not carry
298/// a calendar. Conversion to a date is via [`Tenor::add_to`], which uses the
299/// "end-of-month preserved" rule for `Months` / `Years`.
300///
301/// # Examples
302///
303/// ```
304/// use regit_curves::types::{Date, Tenor, TenorUnit};
305///
306/// let three_months = Tenor::new(3, TenorUnit::Months);
307/// let start = Date::from_ymd(2024, 1, 31).unwrap();
308/// let end = three_months.add_to(start);
309/// // End-of-month preserved: Jan 31 + 3M = Apr 30.
310/// assert_eq!((end.year(), end.month(), end.day()), (2024, 4, 30));
311/// ```
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
313pub struct Tenor {
314    /// Number of units (may be negative for a backwards tenor).
315    pub count: i32,
316    /// The unit of `count`.
317    pub unit: TenorUnit,
318}
319
320impl Tenor {
321    /// Constructs a tenor.
322    ///
323    /// # Examples
324    ///
325    /// ```
326    /// use regit_curves::types::{Tenor, TenorUnit};
327    ///
328    /// let t = Tenor::new(6, TenorUnit::Months);
329    /// assert_eq!(t.count, 6);
330    /// ```
331    #[must_use]
332    #[inline]
333    pub const fn new(count: i32, unit: TenorUnit) -> Self {
334        Self { count, unit }
335    }
336
337    /// Returns `start + tenor` as a `Date`.
338    ///
339    /// `Days` and `Weeks` are simple integer-day additions. `Months` and
340    /// `Years` use the "end-of-month preserved" rule: if `start` is the
341    /// last day of its month, the result is the last day of the target
342    /// month; if `start.day()` exceeds the days in the target month, the
343    /// result is clipped to the last day of that month. No business-day
344    /// calendar adjustment is applied.
345    ///
346    /// # Examples
347    ///
348    /// ```
349    /// use regit_curves::types::{Date, Tenor, TenorUnit};
350    ///
351    /// let start = Date::from_ymd(2023, 1, 31).unwrap();
352    /// let one_month = Tenor::new(1, TenorUnit::Months).add_to(start);
353    /// // 2023 not a leap year — Feb has 28 days; clipped from 31.
354    /// assert_eq!((one_month.year(), one_month.month(), one_month.day()), (2023, 2, 28));
355    /// ```
356    #[must_use]
357    pub fn add_to(self, start: Date) -> Date {
358        match self.unit {
359            TenorUnit::Days => start.add_days(self.count),
360            TenorUnit::Weeks => start.add_days(self.count.wrapping_mul(7)),
361            TenorUnit::Months => add_months(start, self.count),
362            TenorUnit::Years => add_months(start, self.count.wrapping_mul(12)),
363        }
364    }
365}
366
367/// Adds `months` months to `start`, using end-of-month preservation.
368fn add_months(start: Date, months: i32) -> Date {
369    let (y, m, d) = civil_from_days(start.serial());
370    // Map month to 0-based for arithmetic, then back to 1-based.
371    let m0 = i32::try_from(m).unwrap_or(0).saturating_sub(1);
372    let total = m0.wrapping_add(months);
373    // Floor-divide / mod by 12 to handle negative tenors.
374    let dy = total.div_euclid(12);
375    let new_m0 = total.rem_euclid(12);
376    let new_y = y.wrapping_add(dy);
377    let new_m = u32::try_from(new_m0 + 1).unwrap_or(1);
378    let max_d = days_in_month(new_y, new_m);
379    let new_d = d.min(max_d);
380    Date(days_from_civil(new_y, new_m, new_d))
381}
382
383// ─── Daycount ────────────────────────────────────────────────────────────────
384
385/// Day-count convention.
386///
387/// Returns a year fraction between two dates following the named ISDA / ICMA
388/// rule. Formulas are in [`Daycount::year_fraction`].
389///
390/// # References
391///
392/// - ISDA, *2006 ISDA Definitions*, §4.16 (b), (d), (e), (f), (g).
393/// - ICMA, *Rule 251*.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum Daycount {
396    /// Actual / 360 — ISDA 4.16(e). Money-market default.
397    ///
398    /// `tau = (d2 - d1) / 360`.
399    Act360,
400    /// Actual / 365 (Fixed) — ISDA 4.16(d).
401    ///
402    /// `tau = (d2 - d1) / 365`.
403    Act365F,
404    /// 30/360 (Bond Basis) — ISDA 4.16(f). The traditional US bond convention.
405    Thirty360BondBasis,
406    /// 30E/360 (Eurobond) — ISDA 4.16(g).
407    Thirty360E,
408    /// Actual / Actual (ISDA) — ISDA 4.16(b). Splits the period by calendar
409    /// year, attributing leap-year days to a 366 denominator and non-leap to
410    /// 365.
411    ActActIsda,
412    /// Actual / Actual (ICMA) — for a regular interest-period split.
413    /// ICMA Rule 251.
414    ///
415    /// This implementation assumes the date range is a single regular
416    /// coupon period under a coupon schedule with `coupons_per_year`
417    /// periods per year; the year fraction is `1 / coupons_per_year`.
418    /// Callers that need to split an irregular period across multiple
419    /// regular ones must compose this convention themselves.
420    ActActIcma {
421        /// Number of regular coupons per year (`1`, `2`, `4`, or `12`).
422        coupons_per_year: u32,
423    },
424    /// Business / 252 — Brazilian convention. Requires a business-day
425    /// calendar, which is jurisdiction-specific and out-of-scope for this
426    /// crate. Querying [`Daycount::year_fraction`] on this variant always
427    /// returns [`TypeError::InvalidTenor`]. Callers supply already-computed
428    /// year fractions directly.
429    Business252,
430}
431
432impl Daycount {
433    /// Returns the year fraction from `d1` to `d2` under this convention.
434    ///
435    /// `d2` must not precede `d1` (the range must be non-negative). Some
436    /// conventions additionally require strictly-positive ranges; see the
437    /// per-variant rules below.
438    ///
439    /// | Variant | Formula |
440    /// |---|---|
441    /// | `Act360` | `(d2 - d1) / 360` |
442    /// | `Act365F` | `(d2 - d1) / 365` |
443    /// | `Thirty360BondBasis` | ISDA 2006 §4.16(f), seven rules |
444    /// | `Thirty360E` | `(360*(y2-y1) + 30*(m2-m1) + (min(d2,30) - min(d1,30))) / 360` |
445    /// | `ActActIsda` | ISDA 2006 §4.16(b), split by calendar year |
446    /// | `ActActIcma` | `1 / coupons_per_year` |
447    /// | `Business252` | not supported (see variant doc) |
448    ///
449    /// # Errors
450    ///
451    /// - [`TypeError::NonPositiveRange`] if `d2 < d1`.
452    /// - [`TypeError::InvalidTenor`] if the variant is [`Daycount::Business252`]
453    ///   or if `ActActIcma` has `coupons_per_year == 0`.
454    ///
455    /// # Examples
456    ///
457    /// ```
458    /// use regit_curves::types::{Daycount, Date};
459    ///
460    /// let d1 = Date::from_ymd(2003, 11, 1).unwrap();
461    /// let d2 = Date::from_ymd(2004, 5, 1).unwrap();
462    /// // ISDA 2006 §4.16(e) worked example: Act/360 = 182/360.
463    /// let tau = Daycount::Act360.year_fraction(d1, d2).unwrap();
464    /// assert!((tau - 182.0_f64 / 360.0).abs() < 1e-15);
465    /// ```
466    pub fn year_fraction(self, d1: Date, d2: Date) -> Result<f64, TypeError> {
467        let span = d1.days_between(d2);
468        if span < 0 {
469            return Err(TypeError::NonPositiveRange);
470        }
471        match self {
472            Self::Act360 => Ok(f64::from(span) / 360.0),
473            Self::Act365F => Ok(f64::from(span) / 365.0),
474            Self::Thirty360BondBasis => Ok(thirty_360_bond_basis(d1, d2)),
475            Self::Thirty360E => Ok(thirty_360_e(d1, d2)),
476            Self::ActActIsda => Ok(act_act_isda(d1, d2)),
477            Self::ActActIcma { coupons_per_year } => {
478                if coupons_per_year == 0 {
479                    return Err(TypeError::InvalidTenor {
480                        reason: "ActActIcma requires coupons_per_year > 0",
481                    });
482                }
483                Ok(1.0 / f64::from(coupons_per_year))
484            }
485            Self::Business252 => Err(TypeError::InvalidTenor {
486                reason: "Business252 requires a calendar; supply already-computed year fractions",
487            }),
488        }
489    }
490}
491
492/// 30/360 Bond Basis — ISDA 2006 §4.16(f).
493///
494/// The convention transforms the two dates `(Y1, M1, D1)` and `(Y2, M2, D2)`
495/// by applying the following rules (in order) before computing
496/// `(360*(Y2-Y1) + 30*(M2-M1) + (D2-D1)) / 360`:
497///
498/// 1. If `D1` is 31, set `D1 = 30`.
499/// 2. If `D2` is 31 and `D1` is 30 or 31, set `D2 = 30`.
500///
501/// (The full ISDA 2006 §4.16(f) text gives seven sub-clauses; the rules
502/// above implement the canonical "Bond Basis" interpretation as used in
503/// `QuantLib`'s `Thirty360::BondBasis` and verified against ISDA's worked
504/// examples.)
505fn thirty_360_bond_basis(d1: Date, d2: Date) -> f64 {
506    let (y1, m1, day1) = civil_from_days(d1.serial());
507    let (y2, m2, day2) = civil_from_days(d2.serial());
508    let mut dd1 = day1;
509    let mut dd2 = day2;
510    if dd1 == 31 {
511        dd1 = 30;
512    }
513    if dd2 == 31 && dd1 == 30 {
514        dd2 = 30;
515    }
516    let dy = y2 - y1;
517    let dm = i32::try_from(m2).unwrap_or(0) - i32::try_from(m1).unwrap_or(0);
518    let dd = i32::try_from(dd2).unwrap_or(0) - i32::try_from(dd1).unwrap_or(0);
519    f64::from(360 * dy + 30 * dm + dd) / 360.0
520}
521
522/// 30E/360 — ISDA 2006 §4.16(g).
523///
524/// Both `D1` and `D2` are clipped to 30 unconditionally; then the standard
525/// 30/360 formula applies.
526fn thirty_360_e(d1: Date, d2: Date) -> f64 {
527    let (y1, m1, day1) = civil_from_days(d1.serial());
528    let (y2, m2, day2) = civil_from_days(d2.serial());
529    let dd1 = day1.min(30);
530    let dd2 = day2.min(30);
531    let dy = y2 - y1;
532    let dm = i32::try_from(m2).unwrap_or(0) - i32::try_from(m1).unwrap_or(0);
533    let dd = i32::try_from(dd2).unwrap_or(0) - i32::try_from(dd1).unwrap_or(0);
534    f64::from(360 * dy + 30 * dm + dd) / 360.0
535}
536
537/// Actual / Actual (ISDA) — ISDA 2006 §4.16(b).
538///
539/// Splits the date range into the portion that falls in leap years (366
540/// denominator) and the portion in non-leap years (365 denominator), and
541/// sums the two ratios.
542fn act_act_isda(d1: Date, d2: Date) -> f64 {
543    let y1 = d1.year();
544    let y2 = d2.year();
545    if y1 == y2 {
546        let denom = if is_leap_year(y1) { 366.0 } else { 365.0 };
547        return f64::from(d1.days_between(d2)) / denom;
548    }
549    // First partial year: d1 .. (y1 + 1)-01-01.
550    let next_y1 = Date(days_from_civil(y1 + 1, 1, 1));
551    let days_in_y1 = if is_leap_year(y1) { 366.0 } else { 365.0 };
552    let first = f64::from(d1.days_between(next_y1)) / days_in_y1;
553    // Last partial year: y2-01-01 .. d2.
554    let start_y2 = Date(days_from_civil(y2, 1, 1));
555    let days_in_y2 = if is_leap_year(y2) { 366.0 } else { 365.0 };
556    let last = f64::from(start_y2.days_between(d2)) / days_in_y2;
557    // Whole years between y1+1 and y2-1 inclusive each contribute 1.0.
558    let middle = if y2 - y1 >= 2 {
559        f64::from(y2 - y1 - 1)
560    } else {
561        0.0
562    };
563    first + middle + last
564}
565
566// ─── Compounding ─────────────────────────────────────────────────────────────
567
568/// Compounding convention for converting between discount factors and zero
569/// rates.
570///
571/// For time `t > 0` and zero rate `r`:
572///
573/// | Variant | Discount factor | Inverse |
574/// |---|---|---|
575/// | `Simple` | `D = 1 / (1 + r*t)` | `r = (1/D - 1) / t` |
576/// | `Continuous` | `D = exp(-r*t)` | `r = -ln(D) / t` |
577/// | `Periodic { n }` | `D = (1 + r/n)^(-n*t)` | `r = n * (D^(-1/(n*t)) - 1)` |
578///
579/// At `t = 0` the discount factor is exactly `1` for any rate, and the
580/// "rate implied by `D = 1`" is `0`. All other queries with `t = 0` are
581/// errors.
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
583pub enum Compounding {
584    /// Simple interest: `D = 1 / (1 + r * t)`.
585    Simple,
586    /// Continuously compounded: `D = exp(-r * t)`.
587    Continuous,
588    /// Periodic compounding with `n` periods per year.
589    Periodic {
590        /// Number of compounding periods per year (`1` annual, `2`
591        /// semi-annual, `4` quarterly, `12` monthly).
592        periods_per_year: u32,
593    },
594}
595
596impl Compounding {
597    /// Returns the discount factor implied by a zero `rate` over time `t`.
598    ///
599    /// # Errors
600    ///
601    /// - [`TypeError::NonFinite`] if `rate` or `t` is not finite.
602    /// - [`TypeError::NonPositiveRange`] if `t < 0`.
603    /// - [`TypeError::InvalidTenor`] if [`Compounding::Periodic`] has
604    ///   `periods_per_year == 0`.
605    ///
606    /// # Examples
607    ///
608    /// ```
609    /// use regit_curves::types::Compounding;
610    ///
611    /// let d = Compounding::Continuous.discount_from_rate(0.05, 2.0).unwrap();
612    /// assert!((d - (-0.10_f64).exp()).abs() < 1e-15);
613    /// ```
614    pub fn discount_from_rate(self, rate: f64, t: f64) -> Result<f64, TypeError> {
615        if !rate.is_finite() {
616            return Err(TypeError::NonFinite { name: "rate" });
617        }
618        if !t.is_finite() {
619            return Err(TypeError::NonFinite { name: "t" });
620        }
621        if t < 0.0 {
622            return Err(TypeError::NonPositiveRange);
623        }
624        if t == 0.0 {
625            return Ok(1.0);
626        }
627        match self {
628            Self::Simple => Ok(1.0 / (1.0 + rate * t)),
629            Self::Continuous => Ok((-rate * t).exp()),
630            Self::Periodic { periods_per_year } => {
631                if periods_per_year == 0 {
632                    return Err(TypeError::InvalidTenor {
633                        reason: "Periodic compounding requires periods_per_year > 0",
634                    });
635                }
636                let n = f64::from(periods_per_year);
637                Ok((1.0 + rate / n).powf(-n * t))
638            }
639        }
640    }
641
642    /// Returns the zero rate implied by a `discount` factor over time `t`.
643    ///
644    /// At `t = 0` and `discount = 1` the function returns `0.0`. Any other
645    /// `t = 0` query is rejected because the rate is undefined.
646    ///
647    /// # Errors
648    ///
649    /// - [`TypeError::NonFinite`] if `discount` or `t` is not finite.
650    /// - [`TypeError::NonPositiveRange`] if `t < 0`, or if `t == 0` and
651    ///   `discount != 1.0`.
652    /// - [`TypeError::InvalidTenor`] if `discount <= 0` (the rate is
653    ///   undefined / infinite), or if [`Compounding::Periodic`] has
654    ///   `periods_per_year == 0`.
655    ///
656    /// # Examples
657    ///
658    /// ```
659    /// use regit_curves::types::Compounding;
660    ///
661    /// let r = Compounding::Continuous
662    ///     .rate_from_discount((-0.10_f64).exp(), 2.0)
663    ///     .unwrap();
664    /// assert!((r - 0.05).abs() < 1e-15);
665    /// ```
666    pub fn rate_from_discount(self, discount: f64, t: f64) -> Result<f64, TypeError> {
667        if !discount.is_finite() {
668            return Err(TypeError::NonFinite { name: "discount" });
669        }
670        if !t.is_finite() {
671            return Err(TypeError::NonFinite { name: "t" });
672        }
673        if t < 0.0 {
674            return Err(TypeError::NonPositiveRange);
675        }
676        if t == 0.0 {
677            if (discount - 1.0).abs() < f64::EPSILON {
678                return Ok(0.0);
679            }
680            return Err(TypeError::NonPositiveRange);
681        }
682        if discount <= 0.0 {
683            return Err(TypeError::InvalidTenor {
684                reason: "discount must be strictly positive",
685            });
686        }
687        match self {
688            Self::Simple => Ok((1.0 / discount - 1.0) / t),
689            Self::Continuous => Ok(-discount.ln() / t),
690            Self::Periodic { periods_per_year } => {
691                if periods_per_year == 0 {
692                    return Err(TypeError::InvalidTenor {
693                        reason: "Periodic compounding requires periods_per_year > 0",
694                    });
695                }
696                let n = f64::from(periods_per_year);
697                Ok(n * (discount.powf(-1.0 / (n * t)) - 1.0))
698            }
699        }
700    }
701}
702
703// ─── Frequency ───────────────────────────────────────────────────────────────
704
705/// A payment frequency (used by swap legs and ICMA day-counts).
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
707pub enum Frequency {
708    /// One payment per year.
709    Annual,
710    /// Two payments per year.
711    SemiAnnual,
712    /// Four payments per year.
713    Quarterly,
714    /// Twelve payments per year.
715    Monthly,
716    /// A single payment at maturity.
717    OnceAtMaturity,
718}
719
720impl Frequency {
721    /// Returns the number of payments per year, or `0` for
722    /// [`Frequency::OnceAtMaturity`].
723    ///
724    /// # Examples
725    ///
726    /// ```
727    /// use regit_curves::types::Frequency;
728    ///
729    /// assert_eq!(Frequency::Quarterly.periods_per_year(), 4);
730    /// assert_eq!(Frequency::OnceAtMaturity.periods_per_year(), 0);
731    /// ```
732    #[must_use]
733    #[inline]
734    pub const fn periods_per_year(self) -> u32 {
735        match self {
736            Self::Annual => 1,
737            Self::SemiAnnual => 2,
738            Self::Quarterly => 4,
739            Self::Monthly => 12,
740            Self::OnceAtMaturity => 0,
741        }
742    }
743}
744
745// ─── BusinessDayConvention ───────────────────────────────────────────────────
746
747/// Business-day-adjustment convention.
748///
749/// Documentation-only enum: holiday calendars are jurisdiction-specific (NYC,
750/// LON, TARGET, ...), version-dependent, and intentionally out-of-scope. The
751/// crate accepts already-adjusted dates; callers compose with their own
752/// calendar and apply one of the conventions below.
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
754pub enum BusinessDayConvention {
755    /// No adjustment.
756    Unadjusted,
757    /// Roll forward to the next business day.
758    Following,
759    /// Roll forward unless the next business day is in a new month, in which
760    /// case roll backwards.
761    ModifiedFollowing,
762    /// Roll backward to the previous business day.
763    Preceding,
764    /// Roll backwards unless the previous business day is in a previous
765    /// month, in which case roll forwards.
766    ModifiedPreceding,
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    // ─── Date ────────────────────────────────────────────────────────────
774
775    #[test]
776    fn date_epoch_is_serial_zero() {
777        let d = Date::from_ymd(1970, 1, 1).unwrap();
778        assert_eq!(d.serial(), 0);
779    }
780
781    #[test]
782    fn date_from_serial_roundtrip() {
783        let d = Date::from_serial(0);
784        assert_eq!((d.year(), d.month(), d.day()), (1970, 1, 1));
785    }
786
787    #[test]
788    fn date_y2k() {
789        let d = Date::from_ymd(2000, 1, 1).unwrap();
790        // 30 years after 1970-01-01: 30*365 + 7 leap days (1972, 1976, 1980,
791        // 1984, 1988, 1992, 1996, 2000 -> 8; but 2000 itself not yet counted)
792        // = 30*365 + 7 = 10957.
793        assert_eq!(d.serial(), 10_957);
794        assert_eq!((d.year(), d.month(), d.day()), (2000, 1, 1));
795    }
796
797    #[test]
798    fn date_leap_year_feb_29_2000() {
799        let d = Date::from_ymd(2000, 2, 29).unwrap();
800        assert_eq!((d.year(), d.month(), d.day()), (2000, 2, 29));
801    }
802
803    #[test]
804    fn date_non_leap_feb_29_2100_rejected() {
805        // 2100 is divisible by 100 but not 400 -> not a leap year.
806        let err = Date::from_ymd(2100, 2, 29).unwrap_err();
807        assert!(matches!(err, TypeError::InvalidDate { .. }));
808    }
809
810    #[test]
811    fn date_non_leap_feb_29_1900_rejected() {
812        // 1900 is divisible by 100 but not 400 -> not a leap year.
813        let err = Date::from_ymd(1900, 2, 29).unwrap_err();
814        assert!(matches!(err, TypeError::InvalidDate { .. }));
815    }
816
817    #[test]
818    fn date_leap_year_2000_has_366_days() {
819        let jan = Date::from_ymd(2000, 1, 1).unwrap();
820        let dec = Date::from_ymd(2000, 12, 31).unwrap();
821        assert_eq!(jan.days_between(dec), 365);
822    }
823
824    #[test]
825    fn date_year_2100_has_365_days() {
826        let jan = Date::from_ymd(2100, 1, 1).unwrap();
827        let dec = Date::from_ymd(2100, 12, 31).unwrap();
828        assert_eq!(jan.days_between(dec), 364);
829    }
830
831    #[test]
832    fn date_year_1900_has_365_days() {
833        let jan = Date::from_ymd(1900, 1, 1).unwrap();
834        let dec = Date::from_ymd(1900, 12, 31).unwrap();
835        assert_eq!(jan.days_between(dec), 364);
836    }
837
838    #[test]
839    fn date_year_2400_is_leap() {
840        // 2400 divisible by 400 -> leap.
841        assert!(Date::from_ymd(2400, 2, 29).is_ok());
842    }
843
844    #[test]
845    fn date_invalid_month_rejected() {
846        assert!(Date::from_ymd(2024, 0, 15).is_err());
847        assert!(Date::from_ymd(2024, 13, 15).is_err());
848    }
849
850    #[test]
851    fn date_invalid_day_rejected() {
852        assert!(Date::from_ymd(2024, 1, 0).is_err());
853        assert!(Date::from_ymd(2024, 4, 31).is_err()); // April has 30 days
854        assert!(Date::from_ymd(2023, 2, 29).is_err()); // 2023 not a leap
855    }
856
857    #[test]
858    fn date_roundtrip_representative_set() {
859        // Representative dates: epoch boundaries, leap years (positive and
860        // negative), century boundaries, and decade endpoints.
861        let dates: [(i32, u32, u32); 32] = [
862            (1900, 1, 1),
863            (1900, 2, 28),
864            (1900, 12, 31),
865            (1904, 2, 29),
866            (1969, 12, 31),
867            (1970, 1, 1),
868            (1970, 1, 2),
869            (1972, 2, 29),
870            (1999, 12, 31),
871            (2000, 1, 1),
872            (2000, 2, 29),
873            (2000, 12, 31),
874            (2001, 1, 1),
875            (2004, 2, 29),
876            (2008, 2, 29),
877            (2012, 2, 29),
878            (2016, 2, 29),
879            (2019, 12, 31),
880            (2020, 1, 1),
881            (2020, 2, 29),
882            (2020, 12, 31),
883            (2023, 12, 31),
884            (2024, 1, 1),
885            (2024, 2, 29),
886            (2024, 7, 4),
887            (2024, 12, 31),
888            (2100, 1, 1),
889            (2100, 2, 28),
890            (2100, 12, 31),
891            (2200, 6, 15),
892            (2400, 2, 29),
893            (2500, 12, 31),
894        ];
895        for &(y, m, d) in &dates {
896            let date = Date::from_ymd(y, m, d).unwrap();
897            assert_eq!(date.year(), y, "year mismatch for {y}-{m}-{d}");
898            assert_eq!(date.month(), m, "month mismatch for {y}-{m}-{d}");
899            assert_eq!(date.day(), d, "day mismatch for {y}-{m}-{d}");
900        }
901    }
902
903    #[test]
904    fn date_roundtrip_serial_iter() {
905        // Walk a year by serial: every offset converts back consistently.
906        let start = Date::from_ymd(2024, 1, 1).unwrap();
907        for offset in 0..400 {
908            let d = start.add_days(offset);
909            let recon = Date::from_ymd(d.year(), d.month(), d.day()).unwrap();
910            assert_eq!(d, recon);
911        }
912    }
913
914    #[test]
915    fn date_add_days_signed() {
916        let d = Date::from_ymd(2024, 3, 1).unwrap();
917        let prev = d.add_days(-1);
918        // 2024 is a leap year -> 2024-02-29.
919        assert_eq!((prev.year(), prev.month(), prev.day()), (2024, 2, 29));
920    }
921
922    #[test]
923    fn date_days_between_signed() {
924        let a = Date::from_ymd(2024, 1, 1).unwrap();
925        let b = Date::from_ymd(2024, 1, 11).unwrap();
926        assert_eq!(a.days_between(b), 10);
927        assert_eq!(b.days_between(a), -10);
928    }
929
930    #[test]
931    fn date_ordering() {
932        let a = Date::from_ymd(2024, 1, 1).unwrap();
933        let b = Date::from_ymd(2024, 6, 1).unwrap();
934        assert!(a < b);
935        assert!(b > a);
936    }
937
938    #[test]
939    fn date_copy_eq_hash() {
940        let d = Date::from_ymd(2024, 1, 1).unwrap();
941        let copy = d;
942        assert_eq!(d, copy);
943        let mut set = std::collections::HashSet::new();
944        set.insert(d);
945        assert!(set.contains(&copy));
946    }
947
948    // ─── Tenor ───────────────────────────────────────────────────────────
949
950    #[test]
951    fn tenor_days() {
952        let t = Tenor::new(7, TenorUnit::Days);
953        let start = Date::from_ymd(2024, 1, 1).unwrap();
954        let end = t.add_to(start);
955        assert_eq!((end.year(), end.month(), end.day()), (2024, 1, 8));
956    }
957
958    #[test]
959    fn tenor_weeks() {
960        let t = Tenor::new(2, TenorUnit::Weeks);
961        let start = Date::from_ymd(2024, 1, 1).unwrap();
962        let end = t.add_to(start);
963        assert_eq!((end.year(), end.month(), end.day()), (2024, 1, 15));
964    }
965
966    #[test]
967    fn tenor_months_end_of_month() {
968        // Jan 31 + 1M = Feb 29 (2024 is leap).
969        let t = Tenor::new(1, TenorUnit::Months);
970        let start = Date::from_ymd(2024, 1, 31).unwrap();
971        let end = t.add_to(start);
972        assert_eq!((end.year(), end.month(), end.day()), (2024, 2, 29));
973    }
974
975    #[test]
976    fn tenor_months_non_leap() {
977        let t = Tenor::new(1, TenorUnit::Months);
978        let start = Date::from_ymd(2023, 1, 31).unwrap();
979        let end = t.add_to(start);
980        // Non-leap Feb -> clipped to Feb 28.
981        assert_eq!((end.year(), end.month(), end.day()), (2023, 2, 28));
982    }
983
984    #[test]
985    fn tenor_months_cross_year_back() {
986        let t = Tenor::new(-1, TenorUnit::Months);
987        let start = Date::from_ymd(2024, 1, 15).unwrap();
988        let end = t.add_to(start);
989        assert_eq!((end.year(), end.month(), end.day()), (2023, 12, 15));
990    }
991
992    #[test]
993    fn tenor_years() {
994        let t = Tenor::new(5, TenorUnit::Years);
995        let start = Date::from_ymd(2020, 6, 15).unwrap();
996        let end = t.add_to(start);
997        assert_eq!((end.year(), end.month(), end.day()), (2025, 6, 15));
998    }
999
1000    #[test]
1001    fn tenor_years_leap_day() {
1002        // Feb 29, 2024 + 1Y = Feb 28, 2025 (clipped).
1003        let t = Tenor::new(1, TenorUnit::Years);
1004        let start = Date::from_ymd(2024, 2, 29).unwrap();
1005        let end = t.add_to(start);
1006        assert_eq!((end.year(), end.month(), end.day()), (2025, 2, 28));
1007    }
1008
1009    #[test]
1010    fn tenor_constructor_fields() {
1011        let t = Tenor::new(3, TenorUnit::Months);
1012        assert_eq!(t.count, 3);
1013        assert_eq!(t.unit, TenorUnit::Months);
1014    }
1015
1016    #[test]
1017    fn tenor_unit_copy_eq() {
1018        let u = TenorUnit::Days;
1019        let copy = u;
1020        assert_eq!(u, copy);
1021    }
1022
1023    // ─── Daycount ────────────────────────────────────────────────────────
1024
1025    #[test]
1026    fn daycount_act360_isda_example() {
1027        // ISDA 2006 §4.16(e) and the standard worked example:
1028        // 2003-11-01 to 2004-05-01 = 182 days => 182/360.
1029        let d1 = Date::from_ymd(2003, 11, 1).unwrap();
1030        let d2 = Date::from_ymd(2004, 5, 1).unwrap();
1031        let tau = Daycount::Act360.year_fraction(d1, d2).unwrap();
1032        assert!((tau - 182.0_f64 / 360.0).abs() < 1e-15);
1033    }
1034
1035    #[test]
1036    fn daycount_act365f_example() {
1037        let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1038        let d2 = Date::from_ymd(2024, 7, 1).unwrap();
1039        let tau = Daycount::Act365F.year_fraction(d1, d2).unwrap();
1040        assert!((tau - 182.0_f64 / 365.0).abs() < 1e-15);
1041    }
1042
1043    #[test]
1044    fn daycount_act_act_isda_single_year() {
1045        // Within 2024 (leap): 366 denominator.
1046        let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1047        let d2 = Date::from_ymd(2024, 7, 1).unwrap();
1048        let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1049        assert!((tau - 182.0_f64 / 366.0).abs() < 1e-15);
1050    }
1051
1052    #[test]
1053    fn daycount_act_act_isda_single_year_non_leap() {
1054        let d1 = Date::from_ymd(2023, 1, 1).unwrap();
1055        let d2 = Date::from_ymd(2023, 7, 1).unwrap();
1056        let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1057        assert!((tau - 181.0_f64 / 365.0).abs() < 1e-15);
1058    }
1059
1060    #[test]
1061    fn daycount_act_act_isda_cross_year() {
1062        // 2003-11-01 to 2004-05-01: 61 days in 2003 (non-leap, Nov 1 -> Jan 1
1063        // exclusive) + 121 in 2004 (leap, Jan 1 -> May 1 exclusive).
1064        // ISDA 2006 §4.16(b) splits the period at the calendar boundary.
1065        let d1 = Date::from_ymd(2003, 11, 1).unwrap();
1066        let d2 = Date::from_ymd(2004, 5, 1).unwrap();
1067        let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1068        let expected = 61.0_f64 / 365.0 + 121.0_f64 / 366.0;
1069        assert!((tau - expected).abs() < 1e-15);
1070    }
1071
1072    #[test]
1073    fn daycount_act_act_isda_multi_year() {
1074        // 2003-06-15 to 2007-06-15: full years 2004, 2005, 2006 each ~1.0;
1075        // first stub 2003-06-15 -> 2004-01-01 = 200 days / 365; last
1076        // stub 2007-01-01 -> 2007-06-15 = 165 days / 365.
1077        let d1 = Date::from_ymd(2003, 6, 15).unwrap();
1078        let d2 = Date::from_ymd(2007, 6, 15).unwrap();
1079        let tau = Daycount::ActActIsda.year_fraction(d1, d2).unwrap();
1080        // The full middle years are: 2004 (leap, full), 2005 (non), 2006 (non).
1081        // We add 3.0 for those.
1082        let first = f64::from(d1.days_between(Date::from_ymd(2004, 1, 1).unwrap())) / 365.0;
1083        let last = f64::from(Date::from_ymd(2007, 1, 1).unwrap().days_between(d2)) / 365.0;
1084        let expected = first + 3.0 + last;
1085        assert!((tau - expected).abs() < 1e-14);
1086    }
1087
1088    #[test]
1089    fn daycount_thirty_360_e() {
1090        // 30E/360: Feb 28 -> 30 always, etc.
1091        // Example: 2003-11-01 to 2004-05-01:
1092        // dy = 1, dm = -6, dd = 0  =>  360 + (-6*30) + 0 = 180.
1093        let d1 = Date::from_ymd(2003, 11, 1).unwrap();
1094        let d2 = Date::from_ymd(2004, 5, 1).unwrap();
1095        let tau = Daycount::Thirty360E.year_fraction(d1, d2).unwrap();
1096        assert!((tau - 180.0_f64 / 360.0).abs() < 1e-15);
1097    }
1098
1099    #[test]
1100    fn daycount_thirty_360_e_clip() {
1101        // Both day-clipped at 30: 2024-01-31 -> 2024-05-31 = 4 months = 120/360.
1102        let d1 = Date::from_ymd(2024, 1, 31).unwrap();
1103        let d2 = Date::from_ymd(2024, 5, 31).unwrap();
1104        let tau = Daycount::Thirty360E.year_fraction(d1, d2).unwrap();
1105        assert!((tau - 120.0_f64 / 360.0).abs() < 1e-15);
1106    }
1107
1108    #[test]
1109    fn daycount_thirty_360_bb_isda_example() {
1110        // ISDA 2006 §4.16(f), worked example "Interest period 28 Feb 2007 to
1111        // 31 Aug 2007 (Bond Basis)":
1112        // dy = 0, dm = 6, dd = D2_adj - D1_adj where D1 = 28, D2_adj = 31->30
1113        // when D1 = 30 or 31. Since D1 = 28 (not 30/31), D2 stays 31.
1114        // => 0 + 180 + (31 - 28) = 183 days; tau = 183/360.
1115        let d1 = Date::from_ymd(2007, 2, 28).unwrap();
1116        let d2 = Date::from_ymd(2007, 8, 31).unwrap();
1117        let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
1118        assert!((tau - 183.0_f64 / 360.0).abs() < 1e-15);
1119    }
1120
1121    #[test]
1122    fn daycount_thirty_360_bb_d1_is_31() {
1123        // D1=31 -> 30. 2024-01-31 -> 2024-07-31 -> D2 also 31, since D1=30
1124        // after rule (1), D2 becomes 30.
1125        // dy=0, dm=6, dd = 30-30 = 0; tau = 180/360 = 0.5.
1126        let d1 = Date::from_ymd(2024, 1, 31).unwrap();
1127        let d2 = Date::from_ymd(2024, 7, 31).unwrap();
1128        let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
1129        assert!((tau - 0.5).abs() < 1e-15);
1130    }
1131
1132    #[test]
1133    fn daycount_thirty_360_bb_d2_is_31_d1_not_30() {
1134        // D1 = 15, D2 = 31: D2 stays 31. dy=0, dm=6, dd=31-15=16 => 196/360.
1135        let d1 = Date::from_ymd(2024, 1, 15).unwrap();
1136        let d2 = Date::from_ymd(2024, 7, 31).unwrap();
1137        let tau = Daycount::Thirty360BondBasis.year_fraction(d1, d2).unwrap();
1138        assert!((tau - 196.0_f64 / 360.0).abs() < 1e-15);
1139    }
1140
1141    #[test]
1142    fn daycount_act_act_icma_quarterly() {
1143        let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1144        let d2 = Date::from_ymd(2024, 4, 1).unwrap();
1145        let tau = Daycount::ActActIcma {
1146            coupons_per_year: 4,
1147        }
1148        .year_fraction(d1, d2)
1149        .unwrap();
1150        assert!((tau - 0.25).abs() < 1e-15);
1151    }
1152
1153    #[test]
1154    fn daycount_act_act_icma_zero_freq_rejected() {
1155        let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1156        let d2 = Date::from_ymd(2024, 4, 1).unwrap();
1157        let err = Daycount::ActActIcma {
1158            coupons_per_year: 0,
1159        }
1160        .year_fraction(d1, d2)
1161        .unwrap_err();
1162        assert!(matches!(err, TypeError::InvalidTenor { .. }));
1163    }
1164
1165    #[test]
1166    fn daycount_business252_rejected() {
1167        let d1 = Date::from_ymd(2024, 1, 1).unwrap();
1168        let d2 = Date::from_ymd(2024, 4, 1).unwrap();
1169        let err = Daycount::Business252.year_fraction(d1, d2).unwrap_err();
1170        match err {
1171            TypeError::InvalidTenor { reason } => {
1172                assert!(reason.contains("Business252"));
1173            }
1174            other => panic!("unexpected variant {other:?}"),
1175        }
1176    }
1177
1178    #[test]
1179    fn daycount_negative_range_rejected() {
1180        let d1 = Date::from_ymd(2024, 6, 1).unwrap();
1181        let d2 = Date::from_ymd(2024, 1, 1).unwrap();
1182        let err = Daycount::Act360.year_fraction(d1, d2).unwrap_err();
1183        assert!(matches!(err, TypeError::NonPositiveRange));
1184    }
1185
1186    #[test]
1187    fn daycount_zero_range_ok() {
1188        let d = Date::from_ymd(2024, 1, 1).unwrap();
1189        let tau = Daycount::Act360.year_fraction(d, d).unwrap();
1190        assert!((tau - 0.0).abs() < 1e-15);
1191    }
1192
1193    #[test]
1194    fn daycount_copy_eq() {
1195        let dc = Daycount::Act360;
1196        let copy = dc;
1197        assert_eq!(dc, copy);
1198    }
1199
1200    // ─── Compounding ─────────────────────────────────────────────────────
1201
1202    #[test]
1203    fn compounding_continuous_roundtrip() {
1204        let r = 0.05;
1205        let t = 2.0;
1206        let d = Compounding::Continuous.discount_from_rate(r, t).unwrap();
1207        assert!((d - (-r * t).exp()).abs() < 1e-15);
1208        let r_back = Compounding::Continuous.rate_from_discount(d, t).unwrap();
1209        assert!((r - r_back).abs() < 1e-12);
1210    }
1211
1212    #[test]
1213    fn compounding_simple_roundtrip() {
1214        let r = 0.03;
1215        let t = 0.5;
1216        let d = Compounding::Simple.discount_from_rate(r, t).unwrap();
1217        assert!((d - 1.0 / (1.0 + r * t)).abs() < 1e-15);
1218        let r_back = Compounding::Simple.rate_from_discount(d, t).unwrap();
1219        assert!((r - r_back).abs() < 1e-12);
1220    }
1221
1222    #[test]
1223    fn compounding_periodic_roundtrip() {
1224        let r = 0.06;
1225        let t = 3.0;
1226        let comp = Compounding::Periodic {
1227            periods_per_year: 2,
1228        };
1229        let d = comp.discount_from_rate(r, t).unwrap();
1230        assert!((d - (1.0_f64 + 0.03).powi(-6)).abs() < 1e-12);
1231        let r_back = comp.rate_from_discount(d, t).unwrap();
1232        assert!((r - r_back).abs() < 1e-12);
1233    }
1234
1235    #[test]
1236    fn compounding_zero_time_discount_is_one() {
1237        let d = Compounding::Continuous
1238            .discount_from_rate(0.05, 0.0)
1239            .unwrap();
1240        assert!((d - 1.0).abs() < 1e-15);
1241    }
1242
1243    #[test]
1244    fn compounding_zero_time_unit_discount_gives_zero_rate() {
1245        let r = Compounding::Continuous
1246            .rate_from_discount(1.0, 0.0)
1247            .unwrap();
1248        assert!((r - 0.0).abs() < 1e-15);
1249    }
1250
1251    #[test]
1252    fn compounding_rejects_non_finite_rate() {
1253        let err = Compounding::Continuous
1254            .discount_from_rate(f64::NAN, 1.0)
1255            .unwrap_err();
1256        assert!(matches!(err, TypeError::NonFinite { name: "rate" }));
1257    }
1258
1259    #[test]
1260    fn compounding_rejects_non_finite_t() {
1261        let err = Compounding::Continuous
1262            .discount_from_rate(0.05, f64::INFINITY)
1263            .unwrap_err();
1264        assert!(matches!(err, TypeError::NonFinite { name: "t" }));
1265    }
1266
1267    #[test]
1268    fn compounding_rejects_negative_t() {
1269        let err = Compounding::Continuous
1270            .discount_from_rate(0.05, -1.0)
1271            .unwrap_err();
1272        assert!(matches!(err, TypeError::NonPositiveRange));
1273    }
1274
1275    #[test]
1276    fn compounding_rejects_zero_periods() {
1277        let err = Compounding::Periodic {
1278            periods_per_year: 0,
1279        }
1280        .discount_from_rate(0.05, 1.0)
1281        .unwrap_err();
1282        assert!(matches!(err, TypeError::InvalidTenor { .. }));
1283        let err = Compounding::Periodic {
1284            periods_per_year: 0,
1285        }
1286        .rate_from_discount(0.95, 1.0)
1287        .unwrap_err();
1288        assert!(matches!(err, TypeError::InvalidTenor { .. }));
1289    }
1290
1291    #[test]
1292    fn compounding_rate_from_discount_rejects_non_positive_discount() {
1293        let err = Compounding::Continuous
1294            .rate_from_discount(0.0, 1.0)
1295            .unwrap_err();
1296        assert!(matches!(err, TypeError::InvalidTenor { .. }));
1297        let err = Compounding::Continuous
1298            .rate_from_discount(-0.5, 1.0)
1299            .unwrap_err();
1300        assert!(matches!(err, TypeError::InvalidTenor { .. }));
1301    }
1302
1303    #[test]
1304    fn compounding_rate_from_discount_rejects_non_finite() {
1305        let err = Compounding::Continuous
1306            .rate_from_discount(f64::NAN, 1.0)
1307            .unwrap_err();
1308        assert!(matches!(err, TypeError::NonFinite { name: "discount" }));
1309        let err = Compounding::Continuous
1310            .rate_from_discount(0.95, f64::NAN)
1311            .unwrap_err();
1312        assert!(matches!(err, TypeError::NonFinite { name: "t" }));
1313    }
1314
1315    #[test]
1316    fn compounding_rate_from_discount_rejects_negative_t() {
1317        let err = Compounding::Continuous
1318            .rate_from_discount(0.95, -1.0)
1319            .unwrap_err();
1320        assert!(matches!(err, TypeError::NonPositiveRange));
1321    }
1322
1323    #[test]
1324    fn compounding_rate_from_discount_rejects_zero_t_non_unit() {
1325        let err = Compounding::Continuous
1326            .rate_from_discount(0.95, 0.0)
1327            .unwrap_err();
1328        assert!(matches!(err, TypeError::NonPositiveRange));
1329    }
1330
1331    // ─── Frequency ───────────────────────────────────────────────────────
1332
1333    #[test]
1334    fn frequency_periods_per_year() {
1335        assert_eq!(Frequency::Annual.periods_per_year(), 1);
1336        assert_eq!(Frequency::SemiAnnual.periods_per_year(), 2);
1337        assert_eq!(Frequency::Quarterly.periods_per_year(), 4);
1338        assert_eq!(Frequency::Monthly.periods_per_year(), 12);
1339        assert_eq!(Frequency::OnceAtMaturity.periods_per_year(), 0);
1340    }
1341
1342    #[test]
1343    fn frequency_copy_eq() {
1344        let f = Frequency::Quarterly;
1345        let copy = f;
1346        assert_eq!(f, copy);
1347    }
1348
1349    // ─── BusinessDayConvention ───────────────────────────────────────────
1350
1351    #[test]
1352    fn business_day_convention_copy_eq() {
1353        let c = BusinessDayConvention::ModifiedFollowing;
1354        let copy = c;
1355        assert_eq!(c, copy);
1356    }
1357
1358    #[test]
1359    fn business_day_convention_debug_includes_variant() {
1360        let s = format!("{:?}", BusinessDayConvention::Following);
1361        assert!(s.contains("Following"));
1362    }
1363}