///|
/// Convention used when an occurrence lands on a non-working date.
pub(all) enum BusinessDayConvention {
Unadjusted
Following
ModifiedFollowing
Preceding
ModifiedPreceding
} derive(Eq, Compare, Debug)
///|
/// Named workweek and holiday set. Dates are local calendar dates; no timezone
/// assumptions are introduced by business-day adjustment.
pub struct BusinessCalendar {
name : String
working_weekdays : Array[Weekday]
holidays : Array[Date]
} derive(Eq, Debug)
///|
pub suberror BusinessCalendarError {
EmptyWorkingWeek
SearchLimitExceeded(Date, Int)
} derive(Eq, Debug)
///|
fn unique_weekdays(values : Array[Weekday]) -> Array[Weekday] {
let result : Array[Weekday] = []
for value in values {
if !result.contains(value) {
result.push(value)
}
}
result.sort()
result
}
///|
fn unique_dates(values : Array[Date]) -> Array[Date] {
let result : Array[Date] = []
for value in values {
if !result.contains(value) {
result.push(value)
}
}
result.sort()
result
}
///|
pub fn BusinessCalendar::new(
name : String,
working_weekdays? : Array[Weekday] = [
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
],
holidays? : Array[Date] = [],
) -> BusinessCalendar raise BusinessCalendarError {
let week = unique_weekdays(working_weekdays)
if week.length() == 0 {
raise EmptyWorkingWeek
}
{ name, working_weekdays: week, holidays: unique_dates(holidays) }
}
///|
pub fn BusinessCalendar::is_holiday(
self : BusinessCalendar,
date : Date,
) -> Bool {
self.holidays.contains(date)
}
///|
pub fn BusinessCalendar::is_working_day(
self : BusinessCalendar,
date : Date,
) -> Bool {
self.working_weekdays.contains(date.weekday()) && !self.is_holiday(date)
}
///|
pub fn BusinessCalendar::add_holiday(
self : BusinessCalendar,
date : Date,
) -> BusinessCalendar {
let holidays = self.holidays.copy()
if !holidays.contains(date) {
holidays.push(date)
holidays.sort()
}
{ ..self, holidays, }
}
///|
pub fn BusinessCalendar::remove_holiday(
self : BusinessCalendar,
date : Date,
) -> BusinessCalendar {
let holidays : Array[Date] = []
for value in self.holidays {
if value != date {
holidays.push(value)
}
}
{ ..self, holidays, }
}
///|
pub fn BusinessCalendar::with_working_weekdays(
self : BusinessCalendar,
working_weekdays : Array[Weekday],
) -> BusinessCalendar raise BusinessCalendarError {
let week = unique_weekdays(working_weekdays)
if week.length() == 0 {
raise EmptyWorkingWeek
}
{ ..self, working_weekdays: week }
}
///|
pub fn BusinessCalendar::next_working_date(
self : BusinessCalendar,
date : Date,
include_current? : Bool = false,
search_limit? : Int = 370,
) -> Date raise BusinessCalendarError {
let mut candidate = if include_current { date } else { date.add_days(1) }
let mut inspected = 0
while inspected < search_limit {
if self.is_working_day(candidate) {
return candidate
}
candidate = candidate.add_days(1)
inspected += 1
}
raise SearchLimitExceeded(date, inspected)
}
///|
pub fn BusinessCalendar::previous_working_date(
self : BusinessCalendar,
date : Date,
include_current? : Bool = false,
search_limit? : Int = 370,
) -> Date raise BusinessCalendarError {
let mut candidate = if include_current { date } else { date.add_days(-1) }
let mut inspected = 0
while inspected < search_limit {
if self.is_working_day(candidate) {
return candidate
}
candidate = candidate.add_days(-1)
inspected += 1
}
raise SearchLimitExceeded(date, inspected)
}
///|
fn same_month(left : Date, right : Date) -> Bool {
left.year == right.year && left.month == right.month
}
///|
pub fn BusinessCalendar::adjust(
self : BusinessCalendar,
date : Date,
convention : BusinessDayConvention,
) -> Date raise BusinessCalendarError {
if convention == Unadjusted || self.is_working_day(date) {
return date
}
match convention {
Unadjusted => date
Following => self.next_working_date(date)
Preceding => self.previous_working_date(date)
ModifiedFollowing => {
let following = self.next_working_date(date)
if same_month(date, following) {
following
} else {
self.previous_working_date(date)
}
}
ModifiedPreceding => {
let preceding = self.previous_working_date(date)
if same_month(date, preceding) {
preceding
} else {
self.next_working_date(date)
}
}
}
}
///|
pub fn BusinessCalendar::add_business_days(
self : BusinessCalendar,
date : Date,
amount : Int,
) -> Date raise BusinessCalendarError {
if amount == 0 {
return date
}
let direction = if amount > 0 { 1 } else { -1 }
let target = amount.abs()
let mut traversed = 0
let mut candidate = date
let mut inspected = 0
while traversed < target {
candidate = candidate.add_days(direction)
inspected += 1
if inspected > target * 8 + 370 {
raise SearchLimitExceeded(date, inspected)
}
if self.is_working_day(candidate) {
traversed += 1
}
}
candidate
}
///|
/// Count working dates in the half-open interval [start, finish).
pub fn BusinessCalendar::count_business_days(
self : BusinessCalendar,
start : Date,
finish : Date,
) -> Int {
if start == finish {
return 0
}
let direction = if start.to_epoch_day() < finish.to_epoch_day() {
1
} else {
-1
}
let mut count = 0
let mut cursor = start
while cursor != finish {
if self.is_working_day(cursor) {
count += direction
}
cursor = cursor.add_days(direction)
}
count
}
///|
pub fn BusinessCalendar::working_dates_between(
self : BusinessCalendar,
start : Date,
finish : Date,
) -> Array[Date] {
let result : Array[Date] = []
if start.to_epoch_day() >= finish.to_epoch_day() {
return result
}
let mut cursor = start
while cursor != finish {
if self.is_working_day(cursor) {
result.push(cursor)
}
cursor = cursor.add_days(1)
}
result
}
///|
pub struct BusinessDayAdjustment {
original : DateTime
adjusted : DateTime
changed : Bool
convention : BusinessDayConvention
} derive(Eq, Debug)
///|
pub fn BusinessCalendar::adjust_datetime(
self : BusinessCalendar,
value : DateTime,
convention : BusinessDayConvention,
) -> BusinessDayAdjustment raise BusinessCalendarError {
let date = self.adjust(value.date, convention)
{
original: value,
adjusted: { date, time: value.time },
changed: date != value.date,
convention,
}
}
///|
pub fn BusinessCalendar::adjust_occurrences(
self : BusinessCalendar,
values : Array[DateTime],
convention : BusinessDayConvention,
deduplicate? : Bool = true,
) -> Array[BusinessDayAdjustment] raise BusinessCalendarError {
let result : Array[BusinessDayAdjustment] = []
let adjusted_values : Array[DateTime] = []
for value in values {
let adjustment = self.adjust_datetime(value, convention)
if !deduplicate || !adjusted_values.contains(adjustment.adjusted) {
result.push(adjustment)
adjusted_values.push(adjustment.adjusted)
}
}
result
}
///|
pub fn BusinessDayConvention::label(self : BusinessDayConvention) -> String {
match self {
Unadjusted => "unadjusted"
Following => "following"
ModifiedFollowing => "modified-following"
Preceding => "preceding"
ModifiedPreceding => "modified-preceding"
}
}
///|
pub fn business_day_convention_from_string(
text : String,
) -> BusinessDayConvention? {
match text.trim().to_lower() {
"unadjusted" | "none" => Some(Unadjusted)
"following" | "next" => Some(Following)
"modified-following" | "mod-following" => Some(ModifiedFollowing)
"preceding" | "previous" => Some(Preceding)
"modified-preceding" | "mod-preceding" => Some(ModifiedPreceding)
_ => None
}
}
///|
/// Describe the calendar in a stable, diff-friendly text format.
pub fn BusinessCalendar::to_text(self : BusinessCalendar) -> String {
let weekdays = self.working_weekdays.map(value => value.code()).join(",")
let holidays = self.holidays.map(value => value.to_iso_string()).join(",")
"calendar: " +
self.name +
"\nworking-weekdays: " +
weekdays +
"\nholidays: " +
(if holidays.length() == 0 { "none" } else { holidays })
}