Skip to main content

prospicio_core/
period.rs

1//! Calendar months, period grains and origin periods.
2//!
3//! Shared by every crate that keys results by period: `prospicio-reserving`
4//! (triangle origins) and `prospicio-prob` (predictive-distribution components),
5//! so a reserve component joins back to its triangle origin directly.
6//! Moved here unchanged from `prospicio-reserving` (PR #9), apart from the
7//! error type.
8//!
9//! Development ages are whole months measured from the start of the origin
10//! period, so a triangle only ever needs month resolution: an age of 12
11//! months on an origin starting January 2021 is valued at the end of
12//! December 2021.
13
14use std::fmt;
15
16use crate::{Error, Result};
17
18/// Length of an origin or development period.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum Grain {
21    /// One month (`M`).
22    Month,
23    /// Three months (`Q`).
24    Quarter,
25    /// Six months (`S`).
26    Semester,
27    /// Twelve months (`Y`).
28    Year,
29}
30
31impl Grain {
32    /// Length of the period in months.
33    pub const fn months(self) -> u32 {
34        match self {
35            Self::Month => 1,
36            Self::Quarter => 3,
37            Self::Semester => 6,
38            Self::Year => 12,
39        }
40    }
41
42    /// Whether every period of `self` is a whole number of `finer` periods.
43    pub const fn is_multiple_of(self, finer: Grain) -> bool {
44        self.months() % finer.months() == 0
45    }
46}
47
48impl fmt::Display for Grain {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        f.write_str(match self {
51            Self::Month => "M",
52            Self::Quarter => "Q",
53            Self::Semester => "S",
54            Self::Year => "Y",
55        })
56    }
57}
58
59/// A calendar month. As a valuation date it means the end of that month.
60///
61/// ```
62/// use prospicio_core::Month;
63///
64/// let m = Month::new(2021, 11).unwrap();
65/// assert_eq!(m.add_months(3), Month::new(2022, 2).unwrap());
66/// assert_eq!(m.to_string(), "2021-11");
67/// ```
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
69pub struct Month {
70    // Field order gives the chronological `Ord`.
71    year: i32,
72    month: u8,
73}
74
75impl Month {
76    /// The month `month` (1 to 12) of `year`.
77    pub fn new(year: i32, month: u8) -> Result<Self> {
78        if !(1..=12).contains(&month) {
79            return Err(Error::InvalidMonth(month));
80        }
81        Ok(Self { year, month })
82    }
83
84    /// January of `year`.
85    pub const fn january(year: i32) -> Self {
86        Self { year, month: 1 }
87    }
88
89    /// Calendar year.
90    pub const fn year(self) -> i32 {
91        self.year
92    }
93
94    /// Month of the year, 1 to 12.
95    pub const fn month(self) -> u8 {
96        self.month
97    }
98
99    /// Months since January of year 0, so month arithmetic is integer
100    /// arithmetic.
101    const fn ordinal(self) -> i64 {
102        self.year as i64 * 12 + (self.month as i64 - 1)
103    }
104
105    fn from_ordinal(ordinal: i64) -> Self {
106        Self {
107            year: ordinal.div_euclid(12) as i32,
108            month: (ordinal.rem_euclid(12) + 1) as u8,
109        }
110    }
111
112    /// The month `n` months later (earlier if `n` is negative).
113    pub fn add_months(self, n: i64) -> Self {
114        Self::from_ordinal(self.ordinal() + n)
115    }
116
117    /// Whole months from `earlier` to `self` (negative if `self` is earlier).
118    pub const fn months_since(self, earlier: Month) -> i64 {
119        self.ordinal() - earlier.ordinal()
120    }
121
122    /// First month of the `grain` period containing `self`. Periods are
123    /// aligned to the calendar year: quarters start in January, April, July
124    /// and October.
125    pub fn floor(self, grain: Grain) -> Self {
126        let size = grain.months() as u8;
127        Self {
128            year: self.year,
129            month: (self.month - 1) / size * size + 1,
130        }
131    }
132}
133
134impl fmt::Display for Month {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(f, "{}-{:02}", self.year, self.month)
137    }
138}
139
140/// An origin period: a start month and a grain.
141///
142/// Periods compare equal across triangles and reserve distributions, so a
143/// reserve component keyed by origin joins back to the triangle directly.
144///
145/// ```
146/// use prospicio_core::{Grain, Month, Period};
147///
148/// let p = Period::containing(Month::new(2021, 8).unwrap(), Grain::Quarter);
149/// assert_eq!(p.start(), Month::new(2021, 7).unwrap());
150/// assert_eq!(p.end(), Month::new(2021, 9).unwrap());
151/// assert_eq!(p.to_string(), "2021Q3");
152/// ```
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
154pub struct Period {
155    start: Month,
156    grain: Grain,
157}
158
159impl Period {
160    /// The `grain` period that contains `month`.
161    pub fn containing(month: Month, grain: Grain) -> Self {
162        Self {
163            start: month.floor(grain),
164            grain,
165        }
166    }
167
168    /// The calendar year `year`.
169    pub const fn year(year: i32) -> Self {
170        Self {
171            start: Month::january(year),
172            grain: Grain::Year,
173        }
174    }
175
176    /// First month of the period.
177    pub const fn start(self) -> Month {
178        self.start
179    }
180
181    /// Last month of the period.
182    pub fn end(self) -> Month {
183        self.start.add_months(self.grain.months() as i64 - 1)
184    }
185
186    /// Length of the period.
187    pub const fn grain(self) -> Grain {
188        self.grain
189    }
190}
191
192impl fmt::Display for Period {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        let Month { year, month } = self.start;
195        match self.grain {
196            Grain::Year => write!(f, "{year}"),
197            Grain::Semester => write!(f, "{year}H{}", (month - 1) / 6 + 1),
198            Grain::Quarter => write!(f, "{year}Q{}", (month - 1) / 3 + 1),
199            Grain::Month => write!(f, "{}", self.start),
200        }
201    }
202}
203
204/// Development age: whole months from the start of the origin period to the
205/// end of the valuation month. Age 12 on an annual origin is its first
206/// year-end.
207pub type Lag = u32;
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn m(year: i32, month: u8) -> Month {
214        Month::new(year, month).unwrap()
215    }
216
217    #[test]
218    fn month_arithmetic_crosses_years() {
219        assert_eq!(m(2020, 1).add_months(-1), m(2019, 12));
220        assert_eq!(m(2020, 12).add_months(13), m(2022, 1));
221        assert_eq!(m(2022, 1).months_since(m(2020, 12)), 13);
222        assert_eq!(m(-1, 12).add_months(1), m(0, 1));
223    }
224
225    #[test]
226    fn rejects_bad_month() {
227        assert_eq!(Month::new(2020, 0), Err(Error::InvalidMonth(0)));
228        assert_eq!(Month::new(2020, 13), Err(Error::InvalidMonth(13)));
229    }
230
231    #[test]
232    fn floor_aligns_to_calendar() {
233        assert_eq!(m(2021, 12).floor(Grain::Quarter), m(2021, 10));
234        assert_eq!(m(2021, 6).floor(Grain::Semester), m(2021, 1));
235        assert_eq!(m(2021, 7).floor(Grain::Semester), m(2021, 7));
236        assert_eq!(m(2021, 7).floor(Grain::Year), m(2021, 1));
237        assert_eq!(m(2021, 7).floor(Grain::Month), m(2021, 7));
238    }
239
240    #[test]
241    fn period_labels() {
242        assert_eq!(Period::year(1981).to_string(), "1981");
243        assert_eq!(
244            Period::containing(m(2021, 7), Grain::Semester).to_string(),
245            "2021H2"
246        );
247        assert_eq!(
248            Period::containing(m(2021, 3), Grain::Month).to_string(),
249            "2021-03"
250        );
251        assert_eq!(Period::year(1981).end(), m(1981, 12));
252    }
253
254    #[test]
255    fn grain_multiples() {
256        assert!(Grain::Year.is_multiple_of(Grain::Quarter));
257        assert!(Grain::Year.is_multiple_of(Grain::Semester));
258        assert!(!Grain::Quarter.is_multiple_of(Grain::Semester));
259    }
260}