Files
msyrs/src/core/dateseries.rs
2025-04-11 23:55:29 +01:00

282 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! # DateSeries and BDateSeries Implementations
//!
//! This module provides two date-handling types using the [`chrono`](https://docs.rs/chrono) crate:
//!
//! - [`DateSeries`]: Stores any set of calendar dates and allows adding/subtracting *calendar days*.
//! - [`BDateSeries`]: Stores only MondayFriday business days and interprets add/sub as *business day* shifts,
//! skipping weekends (e.g., adding 1 to Friday goes to Monday).
//!
//! Both types also provide a [`from_iso8601_range`](#method.from_iso8601_range) constructor
//! that builds a date series (or businessdate series) from a start/end string (YYYYMMDD).
use chrono::{Datelike, Duration, NaiveDate, ParseResult};
use std::ops::{Add, Sub};
/// Determines if the date is Saturday or Sunday.
fn is_weekend(date: NaiveDate) -> bool {
matches!(date.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun)
}
/// A `DateSeries` stores a list of [`NaiveDate`] values and shifts by **calendar days**.
///
/// ## Example Usage
///
/// ```
/// use chrono::NaiveDate;
/// use msyrs::core::dateseries::DateSeries;
///
/// // Create from explicit dates
/// let ds = DateSeries::new(vec![
/// NaiveDate::from_ymd_opt(2023, 7, 14).unwrap(), // a Friday
/// NaiveDate::from_ymd_opt(2023, 7, 15).unwrap(), // a Saturday
/// ]);
///
/// // Shift forward by 5 calendar days
/// let ds_plus = ds + 5;
/// // 2023-07-14 + 5 => 2023-07-19 (Wednesday)
/// // 2023-07-15 + 5 => 2023-07-20 (Thursday)
///
/// assert_eq!(ds_plus.data()[0], NaiveDate::from_ymd_opt(2023, 7, 19).unwrap());
/// assert_eq!(ds_plus.data()[1], NaiveDate::from_ymd_opt(2023, 7, 20).unwrap());
/// ```
///
#[derive(Debug, Clone)]
pub struct DateSeries {
data: Vec<NaiveDate>,
}
impl DateSeries {
/// Creates a new `DateSeries` from a vector of [`NaiveDate`] values.
///
/// # Panics
/// - Does not panic on invalid weekend or anything; this type accepts all valid dates.
pub fn new(data: Vec<NaiveDate>) -> Self {
Self { data }
}
/// Constructs a `DateSeries` by parsing an ISO8601 start/end string (YYYYMMDD)
/// and including **every calendar date** from start to end (inclusive).
///
/// # Errors
/// - Returns a [`chrono::ParseError`](chrono::ParseError) if parsing fails.
/// - Panics if `start` > `end` chronologically.
///
/// # Examples
///
/// ```
/// use msyrs::core::dateseries::DateSeries;
/// # fn main() -> Result<(), chrono::ParseError> {
/// let ds = DateSeries::from_iso8601_range("2023-07-14", "2023-07-16")?;
/// assert_eq!(ds.data().len(), 3);
/// # Ok(())
/// # }
/// ```
pub fn from_iso8601_range(start: &str, end: &str) -> ParseResult<Self> {
let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d")?;
let end_date = NaiveDate::parse_from_str(end, "%Y-%m-%d")?;
assert!(
start_date <= end_date,
"start date cannot be after end date"
);
let mut dates = Vec::new();
let mut current = start_date;
while current <= end_date {
dates.push(current);
current = current
.checked_add_signed(Duration::days(1))
.expect("Date overflow in from_iso8601_range");
}
Ok(Self::new(dates))
}
/// Returns a reference to the underlying slice of dates.
pub fn data(&self) -> &[NaiveDate] {
&self.data
}
/// Internal helper applying a function to each date.
fn apply<F>(&self, op: F) -> Self
where
F: Fn(NaiveDate) -> NaiveDate,
{
let new_data = self.data.iter().map(|&date| op(date)).collect();
Self { data: new_data }
}
}
/// Implements adding calendar days to each `NaiveDate`.
///
/// If the shifted date goes out of chrono's valid range, it panics.
impl Add<i64> for DateSeries {
type Output = Self;
fn add(self, rhs: i64) -> Self::Output {
self.apply(|date| {
date.checked_add_signed(Duration::days(rhs))
.expect("Overflow in date addition")
})
}
}
/// Implements subtracting calendar days from each `NaiveDate`.
///
/// If the shifted date goes out of chrono's valid range, it panics.
impl Sub<i64> for DateSeries {
type Output = Self;
fn sub(self, rhs: i64) -> Self::Output {
self.apply(|date| {
date.checked_sub_signed(Duration::days(rhs))
.expect("Overflow in date subtraction")
})
}
}
/// A “Business Date Series” for MondayFriday only.
///
/// 1. The constructor disallows weekend dates (panics if any date is Sat/Sun).
/// 2. Adding or subtracting an `i64` interprets that integer as *business days*, skipping weekends.
/// For example, adding 1 to a Friday yields the following Monday.
///
/// ## Example Usage
///
/// ```
/// use chrono::NaiveDate;
/// use msyrs::core::dateseries::BDateSeries;
///
/// // Friday
/// let friday = NaiveDate::from_ymd_opt(2023, 7, 14).unwrap();
/// let mut bds = BDateSeries::new(vec![friday]);
///
/// // Adding 1 “business day” => next Monday, 2023-07-17
/// bds = bds + 1;
/// assert_eq!(bds.data()[0], NaiveDate::from_ymd_opt(2023, 7, 17).unwrap());
/// ```
#[derive(Debug, Clone)]
pub struct BDateSeries {
data: Vec<NaiveDate>,
}
impl BDateSeries {
/// Creates a new `BDateSeries`, panicking if any of the supplied dates is on Saturday/Sunday.
pub fn new(data: Vec<NaiveDate>) -> Self {
for &d in &data {
if is_weekend(d) {
panic!("BDateSeries cannot contain weekend dates: {}", d);
}
}
Self { data }
}
/// Constructs a `BDateSeries` by parsing an ISO8601 start/end string (YYYYMMDD).
///
/// Only MondayFriday dates within `[start, end]` are included in the series.
///
/// # Errors
/// - Returns a [`chrono::ParseError`](chrono::ParseError) if parsing fails.
/// - Panics if `start` > `end` chronologically.
///
/// # Examples
///
/// ```
/// use msyrs::core::dateseries::BDateSeries;
/// # fn main() -> Result<(), chrono::ParseError> {
/// let bds = BDateSeries::from_iso8601_range("2023-07-14", "2023-07-18")?;
/// // 2023-07-14 (Friday), 2023-07-15 (Saturday) => skipped,
/// // 2023-07-16 (Sunday) => skipped,
/// // 2023-07-17 (Monday), 2023-07-18 (Tuesday)
/// // so total 3 valid business days
/// assert_eq!(bds.data().len(), 3);
/// # Ok(())
/// # }
/// ```
pub fn from_iso8601_range(start: &str, end: &str) -> ParseResult<Self> {
let start_date = NaiveDate::parse_from_str(start, "%Y-%m-%d")?;
let end_date = NaiveDate::parse_from_str(end, "%Y-%m-%d")?;
assert!(
start_date <= end_date,
"start date cannot be after end date"
);
let mut dates = Vec::new();
let mut current = start_date;
while current <= end_date {
if !is_weekend(current) {
dates.push(current);
}
current = current
.checked_add_signed(Duration::days(1))
.expect("Date overflow in from_iso8601_range");
}
Ok(Self::new(dates))
}
/// Returns a reference to the underlying slice of dates.
pub fn data(&self) -> &[NaiveDate] {
&self.data
}
/// Internal helper that tries to shift a date forward or backward by one day at a time,
/// skipping weekends, for a total of `delta` business days.
fn shift_business_days(date: NaiveDate, delta: i64) -> NaiveDate {
if delta == 0 {
return date;
}
let step = if delta > 0 { 1 } else { -1 };
let abs_delta = delta.abs();
let mut new_date = date;
for _ in 0..abs_delta {
// Move by 1 day in the correct direction
new_date = new_date
.checked_add_signed(Duration::days(step))
.expect("Overflow in BDateSeries add/sub");
// If we land on weekend, keep moving until Monday..Friday
while is_weekend(new_date) {
new_date = new_date
.checked_add_signed(Duration::days(step))
.expect("Overflow in BDateSeries skipping weekend");
}
}
new_date
}
/// Internal helper to apply a shift of `delta` business days to each date.
fn apply(&self, delta: i64) -> Self {
let new_data = self
.data
.iter()
.map(|&date| Self::shift_business_days(date, delta))
.collect();
Self { data: new_data }
}
}
/// Implement *business day* addition for `BDateSeries`.
///
/// # Panics
/// - If the resulting date(s) overflow `NaiveDate` range.
/// - `BDateSeries` is guaranteed to remain Monday..Friday after the shift.
impl Add<i64> for BDateSeries {
type Output = Self;
fn add(self, rhs: i64) -> Self::Output {
self.apply(rhs)
}
}
/// Implement *business day* subtraction for `BDateSeries`.
///
/// # Panics
/// - If the resulting date(s) overflow `NaiveDate`.
/// - `BDateSeries` is guaranteed to remain Monday..Friday after the shift.
impl Sub<i64> for BDateSeries {
type Output = Self;
fn sub(self, rhs: i64) -> Self::Output {
self.apply(-rhs)
}
}