Skip to main content

datasynth_core/distributions/
business_day.rs

1//! Business day calculations for settlement dates and working day logic.
2//!
3//! Provides financial settlement conventions (T+N), business day arithmetic,
4//! and half-day policy handling for enterprise accounting systems.
5
6use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Weekday};
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9
10use super::holidays::HolidayCalendar;
11
12/// Policy for handling half-day trading sessions.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum HalfDayPolicy {
16    /// Treat half-days as full business days
17    #[default]
18    FullDay,
19    /// Treat half-days as half business days (for counting purposes)
20    HalfDay,
21    /// Treat half-days as non-business days
22    NonBusinessDay,
23}
24
25/// Convention for handling month-end settlement dates.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum MonthEndConvention {
29    /// Move to the following business day; if that's in the next month, move to preceding
30    #[default]
31    ModifiedFollowing,
32    /// Move to the preceding business day
33    Preceding,
34    /// Move to the following business day
35    Following,
36    /// Always use the last business day of the month
37    EndOfMonth,
38}
39
40/// Settlement type specifying how many business days after trade date.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SettlementType {
44    /// T+N settlement (N business days after trade date)
45    TPlus(i32),
46    /// Same-day settlement
47    SameDay,
48    /// Next business day settlement
49    NextBusinessDay,
50    /// End of month settlement
51    MonthEnd,
52}
53
54impl Default for SettlementType {
55    fn default() -> Self {
56        Self::TPlus(2)
57    }
58}
59
60/// Configuration for wire transfer settlement.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WireSettlementConfig {
63    /// Cutoff time for same-day processing (e.g., "14:00")
64    pub cutoff_time: NaiveTime,
65    /// Settlement type when before cutoff
66    pub before_cutoff: SettlementType,
67    /// Settlement type when after cutoff
68    pub after_cutoff: SettlementType,
69}
70
71impl Default for WireSettlementConfig {
72    fn default() -> Self {
73        Self {
74            cutoff_time: NaiveTime::from_hms_opt(14, 0, 0).expect("valid date/time components"),
75            before_cutoff: SettlementType::SameDay,
76            after_cutoff: SettlementType::NextBusinessDay,
77        }
78    }
79}
80
81/// Standard settlement rules for different instrument types.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct SettlementRules {
84    /// Equity settlement (typically T+2)
85    pub equity: SettlementType,
86    /// Government bonds settlement (typically T+1)
87    pub government_bonds: SettlementType,
88    /// FX spot settlement (typically T+2)
89    pub fx_spot: SettlementType,
90    /// FX forward settlement (depends on tenor)
91    pub fx_forward: SettlementType,
92    /// Corporate bonds settlement (typically T+2)
93    pub corporate_bonds: SettlementType,
94    /// Wire transfer (domestic)
95    pub wire_domestic: WireSettlementConfig,
96    /// Wire transfer (international)
97    pub wire_international: SettlementType,
98    /// ACH transfers
99    pub ach: SettlementType,
100}
101
102impl Default for SettlementRules {
103    fn default() -> Self {
104        Self {
105            equity: SettlementType::TPlus(2),
106            government_bonds: SettlementType::TPlus(1),
107            fx_spot: SettlementType::TPlus(2),
108            fx_forward: SettlementType::TPlus(2),
109            corporate_bonds: SettlementType::TPlus(2),
110            wire_domestic: WireSettlementConfig::default(),
111            wire_international: SettlementType::TPlus(1),
112            ach: SettlementType::TPlus(1),
113        }
114    }
115}
116
117/// Calculator for business day operations.
118///
119/// Handles business day arithmetic, settlement date calculation,
120/// and respects regional holiday calendars.
121#[derive(Debug, Clone)]
122pub struct BusinessDayCalculator {
123    /// Holiday calendar for the region
124    calendar: HolidayCalendar,
125    /// Days considered weekend (typically Sat/Sun, but Fri/Sat in some regions)
126    weekend_days: HashSet<Weekday>,
127    /// Policy for half-day sessions
128    half_day_policy: HalfDayPolicy,
129    /// Map of half-day dates (early close days)
130    half_days: HashMap<NaiveDate, NaiveTime>,
131    /// Settlement rules
132    settlement_rules: SettlementRules,
133    /// Month-end convention
134    month_end_convention: MonthEndConvention,
135}
136
137impl BusinessDayCalculator {
138    /// Create a new business day calculator with the given holiday calendar.
139    pub fn new(calendar: HolidayCalendar) -> Self {
140        let mut weekend_days = HashSet::new();
141        weekend_days.insert(Weekday::Sat);
142        weekend_days.insert(Weekday::Sun);
143
144        Self {
145            calendar,
146            weekend_days,
147            half_day_policy: HalfDayPolicy::default(),
148            half_days: HashMap::new(),
149            settlement_rules: SettlementRules::default(),
150            month_end_convention: MonthEndConvention::default(),
151        }
152    }
153
154    /// Create a calculator with custom weekend days (e.g., Fri/Sat for Middle East).
155    pub fn with_weekend_days(mut self, weekend_days: HashSet<Weekday>) -> Self {
156        self.weekend_days = weekend_days;
157        self
158    }
159
160    /// Set the half-day policy.
161    pub fn with_half_day_policy(mut self, policy: HalfDayPolicy) -> Self {
162        self.half_day_policy = policy;
163        self
164    }
165
166    /// Add a half-day (early close) date.
167    pub fn add_half_day(&mut self, date: NaiveDate, close_time: NaiveTime) {
168        self.half_days.insert(date, close_time);
169    }
170
171    /// Set the settlement rules.
172    pub fn with_settlement_rules(mut self, rules: SettlementRules) -> Self {
173        self.settlement_rules = rules;
174        self
175    }
176
177    /// Set the month-end convention.
178    pub fn with_month_end_convention(mut self, convention: MonthEndConvention) -> Self {
179        self.month_end_convention = convention;
180        self
181    }
182
183    /// Check if a date is a weekend day.
184    pub fn is_weekend(&self, date: NaiveDate) -> bool {
185        self.weekend_days.contains(&date.weekday())
186    }
187
188    /// Check if a date is a holiday.
189    pub fn is_holiday(&self, date: NaiveDate) -> bool {
190        self.calendar.is_holiday(date)
191    }
192
193    /// Check if a date is a half-day (early close).
194    pub fn is_half_day(&self, date: NaiveDate) -> bool {
195        self.half_days.contains_key(&date)
196    }
197
198    /// Get the early close time for a half-day, if applicable.
199    pub fn get_half_day_close(&self, date: NaiveDate) -> Option<NaiveTime> {
200        self.half_days.get(&date).copied()
201    }
202
203    /// Check if a date is a business day.
204    ///
205    /// A date is a business day if:
206    /// - It is not a weekend day
207    /// - It is not a bank holiday
208    /// - It is not a half-day treated as non-business (per policy)
209    pub fn is_business_day(&self, date: NaiveDate) -> bool {
210        // Check weekend
211        if self.is_weekend(date) {
212            return false;
213        }
214
215        // Check holidays - bank holidays are not business days
216        // Also treat very low multipliers (< 0.1) as non-business days
217        if self.calendar.is_holiday(date) {
218            let holidays = self.calendar.get_holidays(date);
219            // If any holiday on this date is a bank holiday, it's not a business day
220            if holidays.iter().any(|h| h.is_bank_holiday) {
221                return false;
222            }
223            // Also check if multiplier is very low (effectively closed)
224            let mult = self.calendar.get_multiplier(date);
225            if mult < 0.1 {
226                return false;
227            }
228        }
229
230        // Check half-day policy
231        if self.is_half_day(date) && self.half_day_policy == HalfDayPolicy::NonBusinessDay {
232            return false;
233        }
234
235        true
236    }
237
238    /// Add N business days to a date.
239    ///
240    /// If N is positive, moves forward; if negative, moves backward.
241    pub fn add_business_days(&self, date: NaiveDate, days: i32) -> NaiveDate {
242        if days == 0 {
243            return date;
244        }
245
246        let direction = if days > 0 { 1 } else { -1 };
247        let mut remaining = days.abs();
248        let mut current = date;
249
250        while remaining > 0 {
251            current += Duration::days(direction as i64);
252            if self.is_business_day(current) {
253                remaining -= 1;
254            }
255        }
256
257        current
258    }
259
260    /// Subtract N business days from a date.
261    pub fn sub_business_days(&self, date: NaiveDate, days: i32) -> NaiveDate {
262        self.add_business_days(date, -days)
263    }
264
265    /// Get the next business day on or after the given date.
266    ///
267    /// If `inclusive` is true and the date is a business day, returns the date itself.
268    /// Otherwise, returns the next business day.
269    pub fn next_business_day(&self, date: NaiveDate, inclusive: bool) -> NaiveDate {
270        let mut current = date;
271
272        if inclusive && self.is_business_day(current) {
273            return current;
274        }
275
276        loop {
277            current += Duration::days(1);
278            if self.is_business_day(current) {
279                return current;
280            }
281        }
282    }
283
284    /// Get the previous business day on or before the given date.
285    ///
286    /// If `inclusive` is true and the date is a business day, returns the date itself.
287    /// Otherwise, returns the previous business day.
288    pub fn prev_business_day(&self, date: NaiveDate, inclusive: bool) -> NaiveDate {
289        let mut current = date;
290
291        if inclusive && self.is_business_day(current) {
292            return current;
293        }
294
295        loop {
296            current -= Duration::days(1);
297            if self.is_business_day(current) {
298                return current;
299            }
300        }
301    }
302
303    /// Count business days between two dates (exclusive of end date).
304    ///
305    /// Returns a positive count if end > start, negative if end < start.
306    pub fn business_days_between(&self, start: NaiveDate, end: NaiveDate) -> i32 {
307        if start == end {
308            return 0;
309        }
310
311        let (earlier, later, sign) = if start < end {
312            (start, end, 1)
313        } else {
314            (end, start, -1)
315        };
316
317        let mut count = 0;
318        let mut current = earlier + Duration::days(1);
319
320        while current < later {
321            if self.is_business_day(current) {
322                count += 1;
323            }
324            current += Duration::days(1);
325        }
326
327        count * sign
328    }
329
330    /// Calculate the settlement date for a trade.
331    ///
332    /// The trade date itself is day 0 (T+0). Settlement occurs on T+N
333    /// where N is determined by the settlement type.
334    pub fn settlement_date(&self, trade_date: NaiveDate, settlement: SettlementType) -> NaiveDate {
335        match settlement {
336            SettlementType::TPlus(days) => {
337                // Start from trade date and add N business days
338                self.add_business_days(trade_date, days)
339            }
340            SettlementType::SameDay => {
341                // Same day if it's a business day, otherwise next
342                self.next_business_day(trade_date, true)
343            }
344            SettlementType::NextBusinessDay => {
345                // Next business day after trade date
346                self.next_business_day(trade_date, false)
347            }
348            SettlementType::MonthEnd => {
349                // Last business day of the month
350                self.last_business_day_of_month(trade_date)
351            }
352        }
353    }
354
355    /// Calculate settlement date for a wire transfer.
356    pub fn wire_settlement_date(
357        &self,
358        trade_date: NaiveDate,
359        trade_time: NaiveTime,
360        config: &WireSettlementConfig,
361    ) -> NaiveDate {
362        let settlement_type = if trade_time <= config.cutoff_time {
363            config.before_cutoff
364        } else {
365            config.after_cutoff
366        };
367
368        self.settlement_date(trade_date, settlement_type)
369    }
370
371    /// Get the last business day of the month containing the given date.
372    pub fn last_business_day_of_month(&self, date: NaiveDate) -> NaiveDate {
373        let last_calendar_day = self.last_day_of_month(date);
374        self.prev_business_day(last_calendar_day, true)
375    }
376
377    /// Get the first business day of the month containing the given date.
378    pub fn first_business_day_of_month(&self, date: NaiveDate) -> NaiveDate {
379        let first = NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
380            .expect("valid date/time components");
381        self.next_business_day(first, true)
382    }
383
384    /// Adjust a date according to the month-end convention.
385    ///
386    /// Used when a calculated date falls on a non-business day.
387    pub fn adjust_for_business_day(&self, date: NaiveDate) -> NaiveDate {
388        if self.is_business_day(date) {
389            return date;
390        }
391
392        match self.month_end_convention {
393            MonthEndConvention::Following => self.next_business_day(date, false),
394            MonthEndConvention::Preceding => self.prev_business_day(date, false),
395            MonthEndConvention::ModifiedFollowing => {
396                let following = self.next_business_day(date, false);
397                if following.month() != date.month() {
398                    self.prev_business_day(date, false)
399                } else {
400                    following
401                }
402            }
403            MonthEndConvention::EndOfMonth => self.last_business_day_of_month(date),
404        }
405    }
406
407    /// Get settlement rules.
408    pub fn settlement_rules(&self) -> &SettlementRules {
409        &self.settlement_rules
410    }
411
412    /// Get the last day of the month for a given date.
413    fn last_day_of_month(&self, date: NaiveDate) -> NaiveDate {
414        let year = date.year();
415        let month = date.month();
416
417        if month == 12 {
418            NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid date/time components")
419                - Duration::days(1)
420        } else {
421            NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid date/time components")
422                - Duration::days(1)
423        }
424    }
425
426    /// Get business days in a month.
427    pub fn business_days_in_month(&self, year: i32, month: u32) -> Vec<NaiveDate> {
428        let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid date/time components");
429        let last = self.last_day_of_month(first);
430
431        let mut business_days = Vec::new();
432        let mut current = first;
433
434        while current <= last {
435            if self.is_business_day(current) {
436                business_days.push(current);
437            }
438            current += Duration::days(1);
439        }
440
441        business_days
442    }
443
444    /// Count business days in a month.
445    pub fn count_business_days_in_month(&self, year: i32, month: u32) -> usize {
446        self.business_days_in_month(year, month).len()
447    }
448}
449
450/// Builder for creating a BusinessDayCalculator with common configurations.
451pub struct BusinessDayCalculatorBuilder {
452    calendar: HolidayCalendar,
453    weekend_days: Option<HashSet<Weekday>>,
454    half_day_policy: HalfDayPolicy,
455    half_days: HashMap<NaiveDate, NaiveTime>,
456    settlement_rules: SettlementRules,
457    month_end_convention: MonthEndConvention,
458}
459
460impl BusinessDayCalculatorBuilder {
461    /// Create a new builder with a holiday calendar.
462    pub fn new(calendar: HolidayCalendar) -> Self {
463        Self {
464            calendar,
465            weekend_days: None,
466            half_day_policy: HalfDayPolicy::default(),
467            half_days: HashMap::new(),
468            settlement_rules: SettlementRules::default(),
469            month_end_convention: MonthEndConvention::default(),
470        }
471    }
472
473    /// Set weekend days (default is Saturday and Sunday).
474    pub fn weekend_days(mut self, days: HashSet<Weekday>) -> Self {
475        self.weekend_days = Some(days);
476        self
477    }
478
479    /// Set to Middle East weekend (Friday and Saturday).
480    pub fn middle_east_weekend(mut self) -> Self {
481        let mut days = HashSet::new();
482        days.insert(Weekday::Fri);
483        days.insert(Weekday::Sat);
484        self.weekend_days = Some(days);
485        self
486    }
487
488    /// Set half-day policy.
489    pub fn half_day_policy(mut self, policy: HalfDayPolicy) -> Self {
490        self.half_day_policy = policy;
491        self
492    }
493
494    /// Add a half-day.
495    pub fn add_half_day(mut self, date: NaiveDate, close_time: NaiveTime) -> Self {
496        self.half_days.insert(date, close_time);
497        self
498    }
499
500    /// Add US stock market half-days for a year.
501    ///
502    /// Typically: day before Independence Day, Black Friday, Christmas Eve
503    pub fn add_us_market_half_days(mut self, year: i32) -> Self {
504        let close_time = NaiveTime::from_hms_opt(13, 0, 0).expect("valid date/time components");
505
506        // Day before Independence Day (if July 3 is a weekday)
507        let july_3 = NaiveDate::from_ymd_opt(year, 7, 3).expect("valid date/time components");
508        if !matches!(july_3.weekday(), Weekday::Sat | Weekday::Sun) {
509            self.half_days.insert(july_3, close_time);
510        }
511
512        // Black Friday (day after Thanksgiving - 4th Thursday of November)
513        let first_nov = NaiveDate::from_ymd_opt(year, 11, 1).expect("valid date/time components");
514        let days_until_thu = (Weekday::Thu.num_days_from_monday() as i32
515            - first_nov.weekday().num_days_from_monday() as i32
516            + 7)
517            % 7;
518        let thanksgiving = first_nov + Duration::days(days_until_thu as i64 + 21); // 4th Thursday
519        let black_friday = thanksgiving + Duration::days(1);
520        self.half_days.insert(black_friday, close_time);
521
522        // Christmas Eve (if December 24 is a weekday)
523        let christmas_eve =
524            NaiveDate::from_ymd_opt(year, 12, 24).expect("valid date/time components");
525        if !matches!(christmas_eve.weekday(), Weekday::Sat | Weekday::Sun) {
526            self.half_days.insert(christmas_eve, close_time);
527        }
528
529        self
530    }
531
532    /// Set settlement rules.
533    pub fn settlement_rules(mut self, rules: SettlementRules) -> Self {
534        self.settlement_rules = rules;
535        self
536    }
537
538    /// Set month-end convention.
539    pub fn month_end_convention(mut self, convention: MonthEndConvention) -> Self {
540        self.month_end_convention = convention;
541        self
542    }
543
544    /// Build the BusinessDayCalculator.
545    pub fn build(self) -> BusinessDayCalculator {
546        let mut calc = BusinessDayCalculator::new(self.calendar);
547
548        if let Some(weekend_days) = self.weekend_days {
549            calc.weekend_days = weekend_days;
550        }
551
552        calc.half_day_policy = self.half_day_policy;
553        calc.half_days = self.half_days;
554        calc.settlement_rules = self.settlement_rules;
555        calc.month_end_convention = self.month_end_convention;
556
557        calc
558    }
559}
560
561/// Configuration for business day settings (for YAML/JSON deserialization).
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct BusinessDayConfig {
564    /// Enable business day calculations
565    #[serde(default = "default_true")]
566    pub enabled: bool,
567    /// Half-day policy
568    #[serde(default)]
569    pub half_day_policy: HalfDayPolicy,
570    /// Settlement rules
571    #[serde(default)]
572    pub settlement_rules: SettlementRulesConfig,
573    /// Month-end convention
574    #[serde(default)]
575    pub month_end_convention: MonthEndConvention,
576    /// Weekend days (list of day names like "saturday", "sunday")
577    #[serde(default)]
578    pub weekend_days: Option<Vec<String>>,
579}
580
581fn default_true() -> bool {
582    true
583}
584
585impl Default for BusinessDayConfig {
586    fn default() -> Self {
587        Self {
588            enabled: true,
589            half_day_policy: HalfDayPolicy::default(),
590            settlement_rules: SettlementRulesConfig::default(),
591            month_end_convention: MonthEndConvention::default(),
592            weekend_days: None,
593        }
594    }
595}
596
597/// Settlement rules configuration for YAML/JSON.
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct SettlementRulesConfig {
600    /// Equity settlement days (T+N)
601    #[serde(default = "default_settlement_2")]
602    pub equity_days: i32,
603    /// Government bonds settlement days
604    #[serde(default = "default_settlement_1")]
605    pub government_bonds_days: i32,
606    /// FX spot settlement days
607    #[serde(default = "default_settlement_2")]
608    pub fx_spot_days: i32,
609    /// Corporate bonds settlement days
610    #[serde(default = "default_settlement_2")]
611    pub corporate_bonds_days: i32,
612    /// Wire transfer cutoff time (HH:MM format)
613    #[serde(default = "default_wire_cutoff")]
614    pub wire_cutoff_time: String,
615    /// International wire settlement days
616    #[serde(default = "default_settlement_1")]
617    pub wire_international_days: i32,
618    /// ACH settlement days
619    #[serde(default = "default_settlement_1")]
620    pub ach_days: i32,
621}
622
623fn default_settlement_1() -> i32 {
624    1
625}
626
627fn default_settlement_2() -> i32 {
628    2
629}
630
631fn default_wire_cutoff() -> String {
632    "14:00".to_string()
633}
634
635impl Default for SettlementRulesConfig {
636    fn default() -> Self {
637        Self {
638            equity_days: 2,
639            government_bonds_days: 1,
640            fx_spot_days: 2,
641            corporate_bonds_days: 2,
642            wire_cutoff_time: "14:00".to_string(),
643            wire_international_days: 1,
644            ach_days: 1,
645        }
646    }
647}
648
649impl SettlementRulesConfig {
650    /// Convert to SettlementRules.
651    pub fn to_settlement_rules(&self) -> SettlementRules {
652        let cutoff_time = NaiveTime::parse_from_str(&self.wire_cutoff_time, "%H:%M")
653            .unwrap_or_else(|_| {
654                NaiveTime::from_hms_opt(14, 0, 0).expect("valid date/time components")
655            });
656
657        SettlementRules {
658            equity: SettlementType::TPlus(self.equity_days),
659            government_bonds: SettlementType::TPlus(self.government_bonds_days),
660            fx_spot: SettlementType::TPlus(self.fx_spot_days),
661            fx_forward: SettlementType::TPlus(self.fx_spot_days),
662            corporate_bonds: SettlementType::TPlus(self.corporate_bonds_days),
663            wire_domestic: WireSettlementConfig {
664                cutoff_time,
665                before_cutoff: SettlementType::SameDay,
666                after_cutoff: SettlementType::NextBusinessDay,
667            },
668            wire_international: SettlementType::TPlus(self.wire_international_days),
669            ach: SettlementType::TPlus(self.ach_days),
670        }
671    }
672}
673
674#[cfg(test)]
675#[allow(clippy::unwrap_used)]
676mod tests {
677    use super::*;
678    use crate::distributions::holidays::Region;
679
680    fn test_calendar() -> HolidayCalendar {
681        HolidayCalendar::for_region(Region::US, 2024)
682    }
683
684    #[test]
685    fn test_is_business_day() {
686        let calc = BusinessDayCalculator::new(test_calendar());
687
688        // Regular weekday (Wednesday, no holiday)
689        let wednesday = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
690        assert!(calc.is_business_day(wednesday));
691
692        // Weekend (Saturday)
693        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
694        assert!(!calc.is_business_day(saturday));
695
696        // Holiday (Christmas)
697        let christmas = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap();
698        assert!(!calc.is_business_day(christmas));
699    }
700
701    #[test]
702    fn test_add_business_days() {
703        let calc = BusinessDayCalculator::new(test_calendar());
704
705        // Friday + 1 business day = Monday
706        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
707        let next = calc.add_business_days(friday, 1);
708        assert_eq!(next.weekday(), Weekday::Mon);
709        assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
710
711        // Friday June 14 + 5 business days = Monday June 24
712        // (Mon 17, Tue 18, skip Juneteenth Jun 19, Thu 20, Fri 21, Mon 24)
713        let next_week = calc.add_business_days(friday, 5);
714        assert_eq!(next_week.weekday(), Weekday::Mon);
715        assert_eq!(next_week, NaiveDate::from_ymd_opt(2024, 6, 24).unwrap());
716    }
717
718    #[test]
719    fn test_sub_business_days() {
720        let calc = BusinessDayCalculator::new(test_calendar());
721
722        // Monday - 1 business day = Friday
723        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
724        let prev = calc.sub_business_days(monday, 1);
725        assert_eq!(prev.weekday(), Weekday::Fri);
726        assert_eq!(prev, NaiveDate::from_ymd_opt(2024, 6, 14).unwrap());
727    }
728
729    #[test]
730    fn test_business_days_between() {
731        let calc = BusinessDayCalculator::new(test_calendar());
732
733        // Monday to Friday (same week) = 3 business days between (Tue, Wed, Thu)
734        // Note: exclusive of both start and end dates
735        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
736        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
737        assert_eq!(calc.business_days_between(monday, friday), 3);
738
739        // Same day = 0
740        assert_eq!(calc.business_days_between(monday, monday), 0);
741
742        // Reverse direction
743        assert_eq!(calc.business_days_between(friday, monday), -3);
744
745        // Monday to next Monday (skipping weekend) = 4 business days
746        // Tue, Wed, Thu, Fri between Mon-Mon
747        let next_monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
748        assert_eq!(calc.business_days_between(monday, next_monday), 4);
749    }
750
751    #[test]
752    fn test_settlement_t_plus_2() {
753        let calc = BusinessDayCalculator::new(test_calendar());
754
755        // Trade on Monday, settle on Wednesday (T+2)
756        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
757        let settlement = calc.settlement_date(monday, SettlementType::TPlus(2));
758        assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 12).unwrap());
759
760        // Trade on Thursday, settle on Monday (T+2 skips weekend)
761        let thursday = NaiveDate::from_ymd_opt(2024, 6, 13).unwrap();
762        let settlement = calc.settlement_date(thursday, SettlementType::TPlus(2));
763        assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
764    }
765
766    #[test]
767    fn test_settlement_same_day() {
768        let calc = BusinessDayCalculator::new(test_calendar());
769
770        // Business day - same day
771        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
772        assert_eq!(
773            calc.settlement_date(monday, SettlementType::SameDay),
774            monday
775        );
776
777        // Saturday - moves to Monday
778        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
779        let settlement = calc.settlement_date(saturday, SettlementType::SameDay);
780        assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
781    }
782
783    #[test]
784    fn test_next_business_day() {
785        let calc = BusinessDayCalculator::new(test_calendar());
786
787        // From Saturday, inclusive - moves to Monday
788        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
789        let next = calc.next_business_day(saturday, true);
790        assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
791
792        // From Monday, inclusive - stays Monday
793        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
794        assert_eq!(calc.next_business_day(monday, true), monday);
795
796        // From Monday, not inclusive - moves to Tuesday
797        assert_eq!(
798            calc.next_business_day(monday, false),
799            NaiveDate::from_ymd_opt(2024, 6, 18).unwrap()
800        );
801    }
802
803    #[test]
804    fn test_prev_business_day() {
805        let calc = BusinessDayCalculator::new(test_calendar());
806
807        // From Sunday, inclusive - moves to Friday
808        let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
809        let prev = calc.prev_business_day(sunday, true);
810        assert_eq!(prev, NaiveDate::from_ymd_opt(2024, 6, 14).unwrap());
811    }
812
813    #[test]
814    fn test_last_business_day_of_month() {
815        let calc = BusinessDayCalculator::new(test_calendar());
816
817        // June 2024 ends on Sunday, so last business day is Friday 28th
818        let june = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
819        let last = calc.last_business_day_of_month(june);
820        assert_eq!(last, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap());
821    }
822
823    #[test]
824    fn test_modified_following_convention() {
825        let calc = BusinessDayCalculator::new(test_calendar())
826            .with_month_end_convention(MonthEndConvention::ModifiedFollowing);
827
828        // June 30, 2024 is a Sunday
829        // Following would give July 1, but that's next month
830        // So Modified Following gives Friday June 28
831        let june_30 = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
832        let adjusted = calc.adjust_for_business_day(june_30);
833        assert_eq!(adjusted, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap());
834    }
835
836    #[test]
837    fn test_middle_east_weekend() {
838        let calc = BusinessDayCalculatorBuilder::new(test_calendar())
839            .middle_east_weekend()
840            .build();
841
842        // Friday should be weekend in Middle East
843        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
844        assert!(!calc.is_business_day(friday));
845
846        // Sunday should be a business day
847        let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
848        assert!(calc.is_business_day(sunday));
849    }
850
851    #[test]
852    fn test_half_day_policy() {
853        // Use a date that's not a holiday in the US calendar - July 3, 2024 (Wednesday)
854        let half_day = NaiveDate::from_ymd_opt(2024, 7, 3).unwrap();
855        let close_time = NaiveTime::from_hms_opt(13, 0, 0).unwrap();
856
857        // With HalfDay policy - still a business day
858        let calc_half = BusinessDayCalculatorBuilder::new(test_calendar())
859            .half_day_policy(HalfDayPolicy::HalfDay)
860            .add_half_day(half_day, close_time)
861            .build();
862        assert!(calc_half.is_business_day(half_day));
863        assert_eq!(calc_half.get_half_day_close(half_day), Some(close_time));
864
865        // With NonBusinessDay policy - not a business day
866        let calc_non = BusinessDayCalculatorBuilder::new(test_calendar())
867            .half_day_policy(HalfDayPolicy::NonBusinessDay)
868            .add_half_day(half_day, close_time)
869            .build();
870        assert!(!calc_non.is_business_day(half_day));
871    }
872
873    #[test]
874    fn test_business_days_in_month() {
875        let calc = BusinessDayCalculator::new(test_calendar());
876
877        // June 2024 has 30 days, 8-10 weekend days, and Juneteenth (June 19)
878        // So approximately 20 business days
879        let days = calc.business_days_in_month(2024, 6);
880        assert!(
881            days.len() >= 18 && days.len() <= 22,
882            "Expected 18-22 business days in June 2024, got {}",
883            days.len()
884        );
885
886        // All returned days should be business days
887        for day in &days {
888            assert!(
889                calc.is_business_day(*day),
890                "{} should be a business day",
891                day
892            );
893        }
894    }
895
896    #[test]
897    fn test_wire_settlement() {
898        let calc = BusinessDayCalculator::new(test_calendar());
899        let config = WireSettlementConfig::default();
900
901        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
902
903        // Before cutoff - same day
904        let morning = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
905        assert_eq!(calc.wire_settlement_date(monday, morning, &config), monday);
906
907        // After cutoff - next business day
908        let evening = NaiveTime::from_hms_opt(16, 0, 0).unwrap();
909        let next = calc.wire_settlement_date(monday, evening, &config);
910        assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 11).unwrap());
911    }
912
913    #[test]
914    fn test_settlement_rules_config() {
915        let config = SettlementRulesConfig {
916            equity_days: 3,
917            wire_cutoff_time: "15:30".to_string(),
918            ..Default::default()
919        };
920
921        let rules = config.to_settlement_rules();
922        assert_eq!(rules.equity, SettlementType::TPlus(3));
923        assert_eq!(
924            rules.wire_domestic.cutoff_time,
925            NaiveTime::from_hms_opt(15, 30, 0).unwrap()
926        );
927    }
928}