Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_datime/src/calendar/business_day_engine.rs

18.8 KiB, 65 runs

created by r1870400018:8329, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Advanced business day calculation engine with sophisticated rules.
2//!
3//! This module provides comprehensive business day calculation capabilities,
4//! including custom business week definitions, holiday following rules,
5//! and complex date adjustment algorithms commonly used in financial systems.
6//!
7//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
8//! Anthropic Claude
9
10use crate::{
11 calendar::{CalendarDate, holiday_engines::HolidayEngine},
12 constant::DayOfWeek,
13 time::CalClockZone,
14};
15use oxedyne_fe2o3_core::prelude::*;
16use std::collections::HashSet;
17
18#[derive(Clone, Debug, PartialEq)]
19pub struct BusinessWeek {
20 business_days: HashSet<DayOfWeek>,
21}
22
23/// How a date landing on a non-business day is moved.
24#[derive(Clone, Debug, PartialEq)]
25pub enum BusinessDayAdjustment {
26 None,
27 Following,
28 Preceding,
29 ModifiedFollowing,
30 ModifiedPreceding,
31 EndOfMonth,
32}
33
34#[derive(Clone, Debug)]
35pub struct BusinessDayEngine {
36 business_week: BusinessWeek,
37 holiday_engine: Option<HolidayEngine>,
38 additional_holidays: HashSet<CalendarDate>,
39 default_adjustment: BusinessDayAdjustment,
40}
41
42impl Default for BusinessWeek {
43 /// Monday to Friday.
44 fn default() -> Self {
45 let mut business_days = HashSet::new();
46 business_days.insert(DayOfWeek::Monday);
47 business_days.insert(DayOfWeek::Tuesday);
48 business_days.insert(DayOfWeek::Wednesday);
49 business_days.insert(DayOfWeek::Thursday);
50 business_days.insert(DayOfWeek::Friday);
51
52 Self { business_days }
53 }
54}
55
56impl BusinessWeek {
57 pub fn new() -> Self {
58 Self { business_days: HashSet::new() }
59 }
60
61 pub fn monday_to_friday() -> Self {
62 Self::default()
63 }
64
65 pub fn sunday_to_thursday() -> Self {
66 let mut business_days = HashSet::new();
67 business_days.insert(DayOfWeek::Sunday);
68 business_days.insert(DayOfWeek::Monday);
69 business_days.insert(DayOfWeek::Tuesday);
70 business_days.insert(DayOfWeek::Wednesday);
71 business_days.insert(DayOfWeek::Thursday);
72
73 Self { business_days }
74 }
75
76 pub fn custom(days: Vec<DayOfWeek>) -> Self {
77 let business_days = days.into_iter().collect();
78 Self { business_days }
79 }
80
81 pub fn add_day(mut self, day: DayOfWeek) -> Self {
82 self.business_days.insert(day);
83 self
84 }
85
86 pub fn remove_day(mut self, day: DayOfWeek) -> Self {
87 self.business_days.remove(&day);
88 self
89 }
90
91 pub fn is_business_day(&self, day: DayOfWeek) -> bool {
92 self.business_days.contains(&day)
93 }
94
95 pub fn business_days(&self) -> Vec<DayOfWeek> {
96 let mut days: Vec<_> = self.business_days.iter().cloned().collect();
97 // Sort by weekday order (Sunday = 0, Monday = 1, etc.).
98 days.sort_by_key(|day| day.of());
99 days
100 }
101
102 pub fn business_days_per_week(&self) -> usize {
103 self.business_days.len()
104 }
105}
106
107impl BusinessDayEngine {
108 pub fn new() -> Self {
109 Self {
110 business_week: BusinessWeek::default(),
111 holiday_engine: None,
112 additional_holidays: HashSet::new(),
113 default_adjustment: BusinessDayAdjustment::Following,
114 }
115 }
116
117 pub fn with_business_week(mut self, business_week: BusinessWeek) -> Self {
118 self.business_week = business_week;
119 self
120 }
121
122 pub fn with_holiday_engine(mut self, holiday_engine: HolidayEngine) -> Self {
123 self.holiday_engine = Some(holiday_engine);
124 self
125 }
126
127 pub fn with_default_adjustment(mut self, adjustment: BusinessDayAdjustment) -> Self {
128 self.default_adjustment = adjustment;
129 self
130 }
131
132 pub fn add_holiday(mut self, date: CalendarDate) -> Self {
133 self.additional_holidays.insert(date);
134 self
135 }
136
137 pub fn is_business_day(&self, date: &CalendarDate) -> Outcome<bool> {
138 // Check if the day of week is a business day.
139 if !self.business_week.is_business_day(date.day_of_week()) {
140 return Ok(false);
141 }
142
143 // Check additional holidays.
144 if self.additional_holidays.contains(date) {
145 return Ok(false);
146 }
147
148 // Check holiday engine.
149 if let Some(ref engine) = self.holiday_engine {
150 if res!(engine.is_holiday(date)) {
151 return Ok(false);
152 }
153 }
154
155 Ok(true)
156 }
157
158 pub fn adjust_date(&self, date: &CalendarDate) -> Outcome<CalendarDate> {
159 self.adjust_date_with_rule(date, &self.default_adjustment)
160 }
161
162 pub fn adjust_date_with_rule(&self, date: &CalendarDate, adjustment: &BusinessDayAdjustment) -> Outcome<CalendarDate> {
163 match adjustment {
164 BusinessDayAdjustment::None => Ok(date.clone()),
165 BusinessDayAdjustment::Following => self.following_business_day(date),
166 BusinessDayAdjustment::Preceding => self.preceding_business_day(date),
167 BusinessDayAdjustment::ModifiedFollowing => self.modified_following_business_day(date),
168 BusinessDayAdjustment::ModifiedPreceding => self.modified_preceding_business_day(date),
169 BusinessDayAdjustment::EndOfMonth => self.end_of_month_business_day(date),
170 }
171 }
172
173 pub fn following_business_day(&self, date: &CalendarDate) -> Outcome<CalendarDate> {
174 let mut current = date.clone();
175
176 // Safety limit to prevent infinite loops.
177 for _ in 0..14 { // Maximum 2 weeks search.
178 if res!(self.is_business_day(&current)) {
179 return Ok(current);
180 }
181 current = res!(current.add_days(1));
182 }
183
184 Err(err!("Could not find business day within 14 days of {}", date; Invalid, Input))
185 }
186
187 pub fn preceding_business_day(&self, date: &CalendarDate) -> Outcome<CalendarDate> {
188 let mut current = date.clone();
189
190 // Safety limit to prevent infinite loops.
191 for _ in 0..14 { // Maximum 2 weeks search.
192 if res!(self.is_business_day(&current)) {
193 return Ok(current);
194 }
195 current = res!(current.add_days(-1));
196 }
197
198 Err(err!("Could not find business day within 14 days of {}", date; Invalid, Input))
199 }
200
201 /// Modified following: following business day, but if that's in the next month, use preceding.
202 pub fn modified_following_business_day(&self, date: &CalendarDate) -> Outcome<CalendarDate> {
203 let following = res!(self.following_business_day(date));
204
205 if following.month() != date.month() || following.year() != date.year() {
206 // Following day is in next month, use preceding instead.
207 self.preceding_business_day(date)
208 } else {
209 Ok(following)
210 }
211 }
212
213 /// Modified preceding: preceding business day, but if that's in the previous month, use following.
214 pub fn modified_preceding_business_day(&self, date: &CalendarDate) -> Outcome<CalendarDate> {
215 let preceding = res!(self.preceding_business_day(date));
216
217 if preceding.month() != date.month() || preceding.year() != date.year() {
218 // Preceding day is in previous month, use following instead.
219 self.following_business_day(date)
220 } else {
221 Ok(preceding)
222 }
223 }
224
225 /// End of month: if date is last business day of month, keep it; otherwise use following.
226 pub fn end_of_month_business_day(&self, date: &CalendarDate) -> Outcome<CalendarDate> {
227 // Check if this is the last business day of the month.
228 if res!(self.is_last_business_day_of_month(date)) {
229 Ok(date.clone())
230 } else {
231 self.following_business_day(date)
232 }
233 }
234
235 pub fn is_last_business_day_of_month(&self, date: &CalendarDate) -> Outcome<bool> {
236 // Check if the current date is a business day.
237 if !res!(self.is_business_day(date)) {
238 return Ok(false);
239 }
240
241 // Check all subsequent days in the month.
242 let days_in_month = res!(date.days_in_month());
243 for day in (date.day() + 1)..=days_in_month {
244 let test_date = res!(CalendarDate::from_ymd(
245 date.year(),
246 date.month_of_year(),
247 day,
248 date.zone().clone()
249 ));
250
251 if res!(self.is_business_day(&test_date)) {
252 return Ok(false); // Found another business day this month.
253 }
254 }
255
256 Ok(true)
257 }
258
259 /// A negative count subtracts.
260 pub fn add_business_days(&self, date: &CalendarDate, business_days: i32) -> Outcome<CalendarDate> {
261 let mut current = date.clone();
262 let mut remaining = business_days.abs();
263 let direction = if business_days >= 0 { 1 } else { -1 };
264
265 // Safety limit.
266 let max_iterations = remaining as usize * 14; // Generous limit.
267 let mut iterations = 0;
268
269 while remaining > 0 && iterations < max_iterations {
270 current = res!(current.add_days(direction));
271 iterations += 1;
272
273 if res!(self.is_business_day(&current)) {
274 remaining -= 1;
275 }
276 }
277
278 if remaining > 0 {
279 return Err(err!("Could not add {} business days within reasonable time limit", business_days; Invalid, Input));
280 }
281
282 Ok(current)
283 }
284
285 /// Excludes the start date and includes the end.
286 pub fn business_days_between(&self, start: &CalendarDate, end: &CalendarDate) -> Outcome<i32> {
287 if start > end {
288 return Ok(-res!(self.business_days_between(end, start)));
289 }
290
291 let mut count = 0;
292 let mut current = res!(start.add_days(1)); // Start from day after start date.
293
294 // Safety limit - calculate maximum days between start and end
295 let max_days = if end.year() == start.year() && end.month() == start.month() {
296 (end.day() as i32 - start.day() as i32).abs() + 1
297 } else {
298 // For different months/years, use a generous limit
299 365 * (end.year() - start.year()).abs() + 31
300 };
301 let mut iterations = 0;
302
303 while current <= *end && iterations < max_days {
304 if res!(self.is_business_day(&current)) {
305 count += 1;
306 }
307 current = res!(current.add_days(1));
308 iterations += 1;
309 }
310
311 Ok(count)
312 }
313
314 pub fn last_business_day_of_month(&self, year: i32, month: u8, zone: CalClockZone) -> Outcome<CalendarDate> {
315 use crate::constant::MonthOfYear;
316
317 let month_enum = res!(MonthOfYear::from_number(month));
318 let days_in_month = month_enum.days_in_month(year);
319
320 // Start from the last day and work backwards.
321 for day in (1..=days_in_month).rev() {
322 let candidate = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone()));
323 if res!(self.is_business_day(&candidate)) {
324 return Ok(candidate);
325 }
326 }
327
328 Err(err!("No business day found in {} {}", month_enum, year; Invalid, Input))
329 }
330
331 pub fn first_business_day_of_month(&self, year: i32, month: u8, zone: CalClockZone) -> Outcome<CalendarDate> {
332 use crate::constant::MonthOfYear;
333
334 let month_enum = res!(MonthOfYear::from_number(month));
335 let days_in_month = month_enum.days_in_month(year);
336
337 // Start from the first day and work forwards.
338 for day in 1..=days_in_month {
339 let candidate = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone()));
340 if res!(self.is_business_day(&candidate)) {
341 return Ok(candidate);
342 }
343 }
344
345 Err(err!("No business day found in {} {}", month_enum, year; Invalid, Input))
346 }
347
348 pub fn business_days_in_month(&self, year: i32, month: u8, zone: CalClockZone) -> Outcome<Vec<CalendarDate>> {
349 use crate::constant::MonthOfYear;
350
351 let month_enum = res!(MonthOfYear::from_number(month));
352 let days_in_month = month_enum.days_in_month(year);
353 let mut business_days = Vec::new();
354
355 for day in 1..=days_in_month {
356 let candidate = res!(CalendarDate::from_ymd(year, month_enum, day, zone.clone()));
357 if res!(self.is_business_day(&candidate)) {
358 business_days.push(candidate);
359 }
360 }
361
362 Ok(business_days)
363 }
364
365 pub fn month_business_day_stats(&self, year: i32, month: u8, zone: CalClockZone) -> Outcome<BusinessDayStats> {
366 use crate::constant::MonthOfYear;
367
368 let month_enum = res!(MonthOfYear::from_number(month));
369 let days_in_month = month_enum.days_in_month(year);
370 let business_days = res!(self.business_days_in_month(year, month, zone));
371
372 let first_business_day = business_days.first().cloned();
373 let last_business_day = business_days.last().cloned();
374
375 Ok(BusinessDayStats {
376 total_days: days_in_month,
377 business_days_count: business_days.len() as u8,
378 weekend_days: days_in_month - business_days.len() as u8,
379 first_business_day,
380 last_business_day,
381 business_days,
382 })
383 }
384
385 pub fn business_week(&self) -> &BusinessWeek {
386 &self.business_week
387 }
388
389 pub fn has_holiday_engine(&self) -> bool {
390 self.holiday_engine.is_some()
391 }
392}
393
394#[derive(Clone, Debug)]
395pub struct BusinessDayStats {
396 pub total_days: u8,
397 pub business_days_count: u8,
398 pub weekend_days: u8,
399 pub first_business_day: Option<CalendarDate>,
400 pub last_business_day: Option<CalendarDate>,
401 pub business_days: Vec<CalendarDate>,
402}
403
404impl Default for BusinessDayEngine {
405 fn default() -> Self {
406 Self::new()
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use crate::{
414 calendar::holiday_engines::HolidayEngine,
415 constant::MonthOfYear,
416 time::CalClockZone,
417 };
418
419 #[test]
420 fn test_business_week_definitions() {
421 let standard = BusinessWeek::monday_to_friday();
422 assert!(standard.is_business_day(DayOfWeek::Monday));
423 assert!(standard.is_business_day(DayOfWeek::Friday));
424 assert!(!standard.is_business_day(DayOfWeek::Saturday));
425 assert!(!standard.is_business_day(DayOfWeek::Sunday));
426
427 let middle_east = BusinessWeek::sunday_to_thursday();
428 assert!(middle_east.is_business_day(DayOfWeek::Sunday));
429 assert!(middle_east.is_business_day(DayOfWeek::Thursday));
430 assert!(!middle_east.is_business_day(DayOfWeek::Friday));
431 assert!(!middle_east.is_business_day(DayOfWeek::Saturday));
432 }
433
434 #[test]
435 fn test_business_day_engine() {
436 let zone = CalClockZone::utc();
437 let engine = BusinessDayEngine::new();
438
439 // Monday should be a business day.
440 let monday = CalendarDate::from_ymd(2024, MonthOfYear::June, 3, zone.clone()).unwrap();
441 assert!(engine.is_business_day(&monday).unwrap());
442
443 // Saturday should not be a business day.
444 let saturday = CalendarDate::from_ymd(2024, MonthOfYear::June, 1, zone.clone()).unwrap();
445 assert!(!engine.is_business_day(&saturday).unwrap());
446 }
447
448 #[test]
449 fn test_following_business_day() {
450 let zone = CalClockZone::utc();
451 let engine = BusinessDayEngine::new();
452
453 // Saturday June 1, 2024 -> Monday June 3, 2024.
454 let saturday = CalendarDate::from_ymd(2024, MonthOfYear::June, 1, zone.clone()).unwrap();
455 let following = engine.following_business_day(&saturday).unwrap();
456 assert_eq!(following.day(), 3); // Monday.
457 assert_eq!(following.day_of_week(), DayOfWeek::Monday);
458 }
459
460 #[test]
461 fn test_preceding_business_day() {
462 let zone = CalClockZone::utc();
463 let engine = BusinessDayEngine::new();
464
465 // Sunday June 2, 2024 -> Friday May 31, 2024.
466 let sunday = CalendarDate::from_ymd(2024, MonthOfYear::June, 2, zone.clone()).unwrap();
467 let preceding = engine.preceding_business_day(&sunday).unwrap();
468 assert_eq!(preceding.month(), 5); // May.
469 assert_eq!(preceding.day(), 31); // Friday.
470 assert_eq!(preceding.day_of_week(), DayOfWeek::Friday);
471 }
472
473 #[test]
474 fn test_add_business_days() {
475 let zone = CalClockZone::utc();
476 let engine = BusinessDayEngine::new();
477
478 // Add 5 business days to Friday June 7, 2024 -> Friday June 14, 2024.
479 let friday = CalendarDate::from_ymd(2024, MonthOfYear::June, 7, zone.clone()).unwrap();
480 let result = engine.add_business_days(&friday, 5).unwrap();
481 assert_eq!(result.day(), 14);
482 assert_eq!(result.day_of_week(), DayOfWeek::Friday);
483 }
484
485 #[test]
486 fn test_business_days_between() {
487 let zone = CalClockZone::utc();
488 let engine = BusinessDayEngine::new();
489
490 // From Monday June 3 to Friday June 7, 2024 = 4 business days.
491 let start = CalendarDate::from_ymd(2024, MonthOfYear::June, 3, zone.clone()).unwrap();
492 let end = CalendarDate::from_ymd(2024, MonthOfYear::June, 7, zone.clone()).unwrap();
493 let count = engine.business_days_between(&start, &end).unwrap();
494 assert_eq!(count, 4);
495 }
496
497 #[test]
498 fn test_with_holiday_engine() {
499 let zone = CalClockZone::utc();
500 let holiday_engine = HolidayEngine::us_federal();
501 let business_engine = BusinessDayEngine::new()
502 .with_holiday_engine(holiday_engine);
503
504 // July 4, 2024 is Independence Day (Thursday) - should not be a business day.
505 let july_4 = CalendarDate::from_ymd(2024, MonthOfYear::July, 4, zone.clone()).unwrap();
506 assert!(!business_engine.is_business_day(&july_4).unwrap());
507
508 // July 5, 2024 (Friday) should be a business day.
509 let july_5 = CalendarDate::from_ymd(2024, MonthOfYear::July, 5, zone.clone()).unwrap();
510 assert!(business_engine.is_business_day(&july_5).unwrap());
511 }
512
513 #[test]
514 fn test_month_statistics() {
515 let zone = CalClockZone::utc();
516 let engine = BusinessDayEngine::new();
517 let stats = engine.month_business_day_stats(2024, 6, zone).unwrap(); // June 2024.
518
519 assert_eq!(stats.total_days, 30);
520 assert_eq!(stats.business_days_count, 20); // 20 weekdays in June 2024.
521 assert_eq!(stats.weekend_days, 10); // 10 weekend days.
522 assert!(stats.first_business_day.is_some());
523 assert!(stats.last_business_day.is_some());
524 }
525
526 #[test]
527 fn test_modified_following() {
528 let zone = CalClockZone::utc();
529 let engine = BusinessDayEngine::new();
530
531 // Last day of May 2024 is Friday 31st.
532 // If Saturday June 1st needs adjustment, modified following should give Friday May 31st.
533 let saturday = CalendarDate::from_ymd(2024, MonthOfYear::June, 1, zone.clone()).unwrap();
534 let adjusted = engine.modified_following_business_day(&saturday).unwrap();
535
536 // Should go to preceding business day since following would be in next month.
537 assert_eq!(adjusted.month(), 5); // May.
538 assert_eq!(adjusted.day(), 31); // Friday.
539 }
540}