use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::holidays::HolidayCalendar;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum HalfDayPolicy {
#[default]
FullDay,
HalfDay,
NonBusinessDay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MonthEndConvention {
#[default]
ModifiedFollowing,
Preceding,
Following,
EndOfMonth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SettlementType {
TPlus(i32),
SameDay,
NextBusinessDay,
MonthEnd,
}
impl Default for SettlementType {
fn default() -> Self {
Self::TPlus(2)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireSettlementConfig {
pub cutoff_time: NaiveTime,
pub before_cutoff: SettlementType,
pub after_cutoff: SettlementType,
}
impl Default for WireSettlementConfig {
fn default() -> Self {
Self {
cutoff_time: NaiveTime::from_hms_opt(14, 0, 0).expect("valid date/time components"),
before_cutoff: SettlementType::SameDay,
after_cutoff: SettlementType::NextBusinessDay,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettlementRules {
pub equity: SettlementType,
pub government_bonds: SettlementType,
pub fx_spot: SettlementType,
pub fx_forward: SettlementType,
pub corporate_bonds: SettlementType,
pub wire_domestic: WireSettlementConfig,
pub wire_international: SettlementType,
pub ach: SettlementType,
}
impl Default for SettlementRules {
fn default() -> Self {
Self {
equity: SettlementType::TPlus(2),
government_bonds: SettlementType::TPlus(1),
fx_spot: SettlementType::TPlus(2),
fx_forward: SettlementType::TPlus(2),
corporate_bonds: SettlementType::TPlus(2),
wire_domestic: WireSettlementConfig::default(),
wire_international: SettlementType::TPlus(1),
ach: SettlementType::TPlus(1),
}
}
}
#[derive(Debug, Clone)]
pub struct BusinessDayCalculator {
calendar: HolidayCalendar,
weekend_days: HashSet<Weekday>,
half_day_policy: HalfDayPolicy,
half_days: HashMap<NaiveDate, NaiveTime>,
settlement_rules: SettlementRules,
month_end_convention: MonthEndConvention,
}
impl BusinessDayCalculator {
pub fn new(calendar: HolidayCalendar) -> Self {
let mut weekend_days = HashSet::new();
weekend_days.insert(Weekday::Sat);
weekend_days.insert(Weekday::Sun);
Self {
calendar,
weekend_days,
half_day_policy: HalfDayPolicy::default(),
half_days: HashMap::new(),
settlement_rules: SettlementRules::default(),
month_end_convention: MonthEndConvention::default(),
}
}
pub fn with_weekend_days(mut self, weekend_days: HashSet<Weekday>) -> Self {
self.weekend_days = weekend_days;
self
}
pub fn with_half_day_policy(mut self, policy: HalfDayPolicy) -> Self {
self.half_day_policy = policy;
self
}
pub fn add_half_day(&mut self, date: NaiveDate, close_time: NaiveTime) {
self.half_days.insert(date, close_time);
}
pub fn with_settlement_rules(mut self, rules: SettlementRules) -> Self {
self.settlement_rules = rules;
self
}
pub fn with_month_end_convention(mut self, convention: MonthEndConvention) -> Self {
self.month_end_convention = convention;
self
}
pub fn is_weekend(&self, date: NaiveDate) -> bool {
self.weekend_days.contains(&date.weekday())
}
pub fn is_holiday(&self, date: NaiveDate) -> bool {
self.calendar.is_holiday(date)
}
pub fn is_half_day(&self, date: NaiveDate) -> bool {
self.half_days.contains_key(&date)
}
pub fn get_half_day_close(&self, date: NaiveDate) -> Option<NaiveTime> {
self.half_days.get(&date).copied()
}
pub fn is_business_day(&self, date: NaiveDate) -> bool {
if self.is_weekend(date) {
return false;
}
if self.calendar.is_holiday(date) {
let holidays = self.calendar.get_holidays(date);
if holidays.iter().any(|h| h.is_bank_holiday) {
return false;
}
let mult = self.calendar.get_multiplier(date);
if mult < 0.1 {
return false;
}
}
if self.is_half_day(date) && self.half_day_policy == HalfDayPolicy::NonBusinessDay {
return false;
}
true
}
pub fn add_business_days(&self, date: NaiveDate, days: i32) -> NaiveDate {
if days == 0 {
return date;
}
let direction = if days > 0 { 1 } else { -1 };
let mut remaining = days.abs();
let mut current = date;
while remaining > 0 {
current += Duration::days(direction as i64);
if self.is_business_day(current) {
remaining -= 1;
}
}
current
}
pub fn sub_business_days(&self, date: NaiveDate, days: i32) -> NaiveDate {
self.add_business_days(date, -days)
}
pub fn next_business_day(&self, date: NaiveDate, inclusive: bool) -> NaiveDate {
let mut current = date;
if inclusive && self.is_business_day(current) {
return current;
}
loop {
current += Duration::days(1);
if self.is_business_day(current) {
return current;
}
}
}
pub fn prev_business_day(&self, date: NaiveDate, inclusive: bool) -> NaiveDate {
let mut current = date;
if inclusive && self.is_business_day(current) {
return current;
}
loop {
current -= Duration::days(1);
if self.is_business_day(current) {
return current;
}
}
}
pub fn business_days_between(&self, start: NaiveDate, end: NaiveDate) -> i32 {
if start == end {
return 0;
}
let (earlier, later, sign) = if start < end {
(start, end, 1)
} else {
(end, start, -1)
};
let mut count = 0;
let mut current = earlier + Duration::days(1);
while current < later {
if self.is_business_day(current) {
count += 1;
}
current += Duration::days(1);
}
count * sign
}
pub fn settlement_date(&self, trade_date: NaiveDate, settlement: SettlementType) -> NaiveDate {
match settlement {
SettlementType::TPlus(days) => {
self.add_business_days(trade_date, days)
}
SettlementType::SameDay => {
self.next_business_day(trade_date, true)
}
SettlementType::NextBusinessDay => {
self.next_business_day(trade_date, false)
}
SettlementType::MonthEnd => {
self.last_business_day_of_month(trade_date)
}
}
}
pub fn wire_settlement_date(
&self,
trade_date: NaiveDate,
trade_time: NaiveTime,
config: &WireSettlementConfig,
) -> NaiveDate {
let settlement_type = if trade_time <= config.cutoff_time {
config.before_cutoff
} else {
config.after_cutoff
};
self.settlement_date(trade_date, settlement_type)
}
pub fn last_business_day_of_month(&self, date: NaiveDate) -> NaiveDate {
let last_calendar_day = self.last_day_of_month(date);
self.prev_business_day(last_calendar_day, true)
}
pub fn first_business_day_of_month(&self, date: NaiveDate) -> NaiveDate {
let first = NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
.expect("valid date/time components");
self.next_business_day(first, true)
}
pub fn adjust_for_business_day(&self, date: NaiveDate) -> NaiveDate {
if self.is_business_day(date) {
return date;
}
match self.month_end_convention {
MonthEndConvention::Following => self.next_business_day(date, false),
MonthEndConvention::Preceding => self.prev_business_day(date, false),
MonthEndConvention::ModifiedFollowing => {
let following = self.next_business_day(date, false);
if following.month() != date.month() {
self.prev_business_day(date, false)
} else {
following
}
}
MonthEndConvention::EndOfMonth => self.last_business_day_of_month(date),
}
}
pub fn settlement_rules(&self) -> &SettlementRules {
&self.settlement_rules
}
fn last_day_of_month(&self, date: NaiveDate) -> NaiveDate {
let year = date.year();
let month = date.month();
if month == 12 {
NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid date/time components")
- Duration::days(1)
} else {
NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid date/time components")
- Duration::days(1)
}
}
pub fn business_days_in_month(&self, year: i32, month: u32) -> Vec<NaiveDate> {
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid date/time components");
let last = self.last_day_of_month(first);
let mut business_days = Vec::new();
let mut current = first;
while current <= last {
if self.is_business_day(current) {
business_days.push(current);
}
current += Duration::days(1);
}
business_days
}
pub fn count_business_days_in_month(&self, year: i32, month: u32) -> usize {
self.business_days_in_month(year, month).len()
}
}
pub struct BusinessDayCalculatorBuilder {
calendar: HolidayCalendar,
weekend_days: Option<HashSet<Weekday>>,
half_day_policy: HalfDayPolicy,
half_days: HashMap<NaiveDate, NaiveTime>,
settlement_rules: SettlementRules,
month_end_convention: MonthEndConvention,
}
impl BusinessDayCalculatorBuilder {
pub fn new(calendar: HolidayCalendar) -> Self {
Self {
calendar,
weekend_days: None,
half_day_policy: HalfDayPolicy::default(),
half_days: HashMap::new(),
settlement_rules: SettlementRules::default(),
month_end_convention: MonthEndConvention::default(),
}
}
pub fn weekend_days(mut self, days: HashSet<Weekday>) -> Self {
self.weekend_days = Some(days);
self
}
pub fn middle_east_weekend(mut self) -> Self {
let mut days = HashSet::new();
days.insert(Weekday::Fri);
days.insert(Weekday::Sat);
self.weekend_days = Some(days);
self
}
pub fn half_day_policy(mut self, policy: HalfDayPolicy) -> Self {
self.half_day_policy = policy;
self
}
pub fn add_half_day(mut self, date: NaiveDate, close_time: NaiveTime) -> Self {
self.half_days.insert(date, close_time);
self
}
pub fn add_us_market_half_days(mut self, year: i32) -> Self {
let close_time = NaiveTime::from_hms_opt(13, 0, 0).expect("valid date/time components");
let july_3 = NaiveDate::from_ymd_opt(year, 7, 3).expect("valid date/time components");
if !matches!(july_3.weekday(), Weekday::Sat | Weekday::Sun) {
self.half_days.insert(july_3, close_time);
}
let first_nov = NaiveDate::from_ymd_opt(year, 11, 1).expect("valid date/time components");
let days_until_thu = (Weekday::Thu.num_days_from_monday() as i32
- first_nov.weekday().num_days_from_monday() as i32
+ 7)
% 7;
let thanksgiving = first_nov + Duration::days(days_until_thu as i64 + 21); let black_friday = thanksgiving + Duration::days(1);
self.half_days.insert(black_friday, close_time);
let christmas_eve =
NaiveDate::from_ymd_opt(year, 12, 24).expect("valid date/time components");
if !matches!(christmas_eve.weekday(), Weekday::Sat | Weekday::Sun) {
self.half_days.insert(christmas_eve, close_time);
}
self
}
pub fn settlement_rules(mut self, rules: SettlementRules) -> Self {
self.settlement_rules = rules;
self
}
pub fn month_end_convention(mut self, convention: MonthEndConvention) -> Self {
self.month_end_convention = convention;
self
}
pub fn build(self) -> BusinessDayCalculator {
let mut calc = BusinessDayCalculator::new(self.calendar);
if let Some(weekend_days) = self.weekend_days {
calc.weekend_days = weekend_days;
}
calc.half_day_policy = self.half_day_policy;
calc.half_days = self.half_days;
calc.settlement_rules = self.settlement_rules;
calc.month_end_convention = self.month_end_convention;
calc
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BusinessDayConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub half_day_policy: HalfDayPolicy,
#[serde(default)]
pub settlement_rules: SettlementRulesConfig,
#[serde(default)]
pub month_end_convention: MonthEndConvention,
#[serde(default)]
pub weekend_days: Option<Vec<String>>,
}
fn default_true() -> bool {
true
}
impl Default for BusinessDayConfig {
fn default() -> Self {
Self {
enabled: true,
half_day_policy: HalfDayPolicy::default(),
settlement_rules: SettlementRulesConfig::default(),
month_end_convention: MonthEndConvention::default(),
weekend_days: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettlementRulesConfig {
#[serde(default = "default_settlement_2")]
pub equity_days: i32,
#[serde(default = "default_settlement_1")]
pub government_bonds_days: i32,
#[serde(default = "default_settlement_2")]
pub fx_spot_days: i32,
#[serde(default = "default_settlement_2")]
pub corporate_bonds_days: i32,
#[serde(default = "default_wire_cutoff")]
pub wire_cutoff_time: String,
#[serde(default = "default_settlement_1")]
pub wire_international_days: i32,
#[serde(default = "default_settlement_1")]
pub ach_days: i32,
}
fn default_settlement_1() -> i32 {
1
}
fn default_settlement_2() -> i32 {
2
}
fn default_wire_cutoff() -> String {
"14:00".to_string()
}
impl Default for SettlementRulesConfig {
fn default() -> Self {
Self {
equity_days: 2,
government_bonds_days: 1,
fx_spot_days: 2,
corporate_bonds_days: 2,
wire_cutoff_time: "14:00".to_string(),
wire_international_days: 1,
ach_days: 1,
}
}
}
impl SettlementRulesConfig {
pub fn to_settlement_rules(&self) -> SettlementRules {
let cutoff_time = NaiveTime::parse_from_str(&self.wire_cutoff_time, "%H:%M")
.unwrap_or_else(|_| {
NaiveTime::from_hms_opt(14, 0, 0).expect("valid date/time components")
});
SettlementRules {
equity: SettlementType::TPlus(self.equity_days),
government_bonds: SettlementType::TPlus(self.government_bonds_days),
fx_spot: SettlementType::TPlus(self.fx_spot_days),
fx_forward: SettlementType::TPlus(self.fx_spot_days),
corporate_bonds: SettlementType::TPlus(self.corporate_bonds_days),
wire_domestic: WireSettlementConfig {
cutoff_time,
before_cutoff: SettlementType::SameDay,
after_cutoff: SettlementType::NextBusinessDay,
},
wire_international: SettlementType::TPlus(self.wire_international_days),
ach: SettlementType::TPlus(self.ach_days),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::distributions::holidays::Region;
fn test_calendar() -> HolidayCalendar {
HolidayCalendar::for_region(Region::US, 2024)
}
#[test]
fn test_is_business_day() {
let calc = BusinessDayCalculator::new(test_calendar());
let wednesday = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
assert!(calc.is_business_day(wednesday));
let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
assert!(!calc.is_business_day(saturday));
let christmas = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap();
assert!(!calc.is_business_day(christmas));
}
#[test]
fn test_add_business_days() {
let calc = BusinessDayCalculator::new(test_calendar());
let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
let next = calc.add_business_days(friday, 1);
assert_eq!(next.weekday(), Weekday::Mon);
assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
let next_week = calc.add_business_days(friday, 5);
assert_eq!(next_week.weekday(), Weekday::Mon);
assert_eq!(next_week, NaiveDate::from_ymd_opt(2024, 6, 24).unwrap());
}
#[test]
fn test_sub_business_days() {
let calc = BusinessDayCalculator::new(test_calendar());
let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
let prev = calc.sub_business_days(monday, 1);
assert_eq!(prev.weekday(), Weekday::Fri);
assert_eq!(prev, NaiveDate::from_ymd_opt(2024, 6, 14).unwrap());
}
#[test]
fn test_business_days_between() {
let calc = BusinessDayCalculator::new(test_calendar());
let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
assert_eq!(calc.business_days_between(monday, friday), 3);
assert_eq!(calc.business_days_between(monday, monday), 0);
assert_eq!(calc.business_days_between(friday, monday), -3);
let next_monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
assert_eq!(calc.business_days_between(monday, next_monday), 4);
}
#[test]
fn test_settlement_t_plus_2() {
let calc = BusinessDayCalculator::new(test_calendar());
let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
let settlement = calc.settlement_date(monday, SettlementType::TPlus(2));
assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 12).unwrap());
let thursday = NaiveDate::from_ymd_opt(2024, 6, 13).unwrap();
let settlement = calc.settlement_date(thursday, SettlementType::TPlus(2));
assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
}
#[test]
fn test_settlement_same_day() {
let calc = BusinessDayCalculator::new(test_calendar());
let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
assert_eq!(
calc.settlement_date(monday, SettlementType::SameDay),
monday
);
let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let settlement = calc.settlement_date(saturday, SettlementType::SameDay);
assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
}
#[test]
fn test_next_business_day() {
let calc = BusinessDayCalculator::new(test_calendar());
let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let next = calc.next_business_day(saturday, true);
assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
assert_eq!(calc.next_business_day(monday, true), monday);
assert_eq!(
calc.next_business_day(monday, false),
NaiveDate::from_ymd_opt(2024, 6, 18).unwrap()
);
}
#[test]
fn test_prev_business_day() {
let calc = BusinessDayCalculator::new(test_calendar());
let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
let prev = calc.prev_business_day(sunday, true);
assert_eq!(prev, NaiveDate::from_ymd_opt(2024, 6, 14).unwrap());
}
#[test]
fn test_last_business_day_of_month() {
let calc = BusinessDayCalculator::new(test_calendar());
let june = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
let last = calc.last_business_day_of_month(june);
assert_eq!(last, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap());
}
#[test]
fn test_modified_following_convention() {
let calc = BusinessDayCalculator::new(test_calendar())
.with_month_end_convention(MonthEndConvention::ModifiedFollowing);
let june_30 = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
let adjusted = calc.adjust_for_business_day(june_30);
assert_eq!(adjusted, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap());
}
#[test]
fn test_middle_east_weekend() {
let calc = BusinessDayCalculatorBuilder::new(test_calendar())
.middle_east_weekend()
.build();
let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
assert!(!calc.is_business_day(friday));
let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
assert!(calc.is_business_day(sunday));
}
#[test]
fn test_half_day_policy() {
let half_day = NaiveDate::from_ymd_opt(2024, 7, 3).unwrap();
let close_time = NaiveTime::from_hms_opt(13, 0, 0).unwrap();
let calc_half = BusinessDayCalculatorBuilder::new(test_calendar())
.half_day_policy(HalfDayPolicy::HalfDay)
.add_half_day(half_day, close_time)
.build();
assert!(calc_half.is_business_day(half_day));
assert_eq!(calc_half.get_half_day_close(half_day), Some(close_time));
let calc_non = BusinessDayCalculatorBuilder::new(test_calendar())
.half_day_policy(HalfDayPolicy::NonBusinessDay)
.add_half_day(half_day, close_time)
.build();
assert!(!calc_non.is_business_day(half_day));
}
#[test]
fn test_business_days_in_month() {
let calc = BusinessDayCalculator::new(test_calendar());
let days = calc.business_days_in_month(2024, 6);
assert!(
days.len() >= 18 && days.len() <= 22,
"Expected 18-22 business days in June 2024, got {}",
days.len()
);
for day in &days {
assert!(
calc.is_business_day(*day),
"{} should be a business day",
day
);
}
}
#[test]
fn test_wire_settlement() {
let calc = BusinessDayCalculator::new(test_calendar());
let config = WireSettlementConfig::default();
let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
let morning = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
assert_eq!(calc.wire_settlement_date(monday, morning, &config), monday);
let evening = NaiveTime::from_hms_opt(16, 0, 0).unwrap();
let next = calc.wire_settlement_date(monday, evening, &config);
assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 11).unwrap());
}
#[test]
fn test_settlement_rules_config() {
let config = SettlementRulesConfig {
equity_days: 3,
wire_cutoff_time: "15:30".to_string(),
..Default::default()
};
let rules = config.to_settlement_rules();
assert_eq!(rules.equity, SettlementType::TPlus(3));
assert_eq!(
rules.wire_domestic.cutoff_time,
NaiveTime::from_hms_opt(15, 30, 0).unwrap()
);
}
}