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).unwrap(),
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).unwrap();
380        self.next_business_day(first, true)
381    }
382
383    /// Adjust a date according to the month-end convention.
384    ///
385    /// Used when a calculated date falls on a non-business day.
386    pub fn adjust_for_business_day(&self, date: NaiveDate) -> NaiveDate {
387        if self.is_business_day(date) {
388            return date;
389        }
390
391        match self.month_end_convention {
392            MonthEndConvention::Following => self.next_business_day(date, false),
393            MonthEndConvention::Preceding => self.prev_business_day(date, false),
394            MonthEndConvention::ModifiedFollowing => {
395                let following = self.next_business_day(date, false);
396                if following.month() != date.month() {
397                    self.prev_business_day(date, false)
398                } else {
399                    following
400                }
401            }
402            MonthEndConvention::EndOfMonth => self.last_business_day_of_month(date),
403        }
404    }
405
406    /// Get settlement rules.
407    pub fn settlement_rules(&self) -> &SettlementRules {
408        &self.settlement_rules
409    }
410
411    /// Get the last day of the month for a given date.
412    fn last_day_of_month(&self, date: NaiveDate) -> NaiveDate {
413        let year = date.year();
414        let month = date.month();
415
416        if month == 12 {
417            NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() - Duration::days(1)
418        } else {
419            NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() - Duration::days(1)
420        }
421    }
422
423    /// Get business days in a month.
424    pub fn business_days_in_month(&self, year: i32, month: u32) -> Vec<NaiveDate> {
425        let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
426        let last = self.last_day_of_month(first);
427
428        let mut business_days = Vec::new();
429        let mut current = first;
430
431        while current <= last {
432            if self.is_business_day(current) {
433                business_days.push(current);
434            }
435            current += Duration::days(1);
436        }
437
438        business_days
439    }
440
441    /// Count business days in a month.
442    pub fn count_business_days_in_month(&self, year: i32, month: u32) -> usize {
443        self.business_days_in_month(year, month).len()
444    }
445}
446
447/// Builder for creating a BusinessDayCalculator with common configurations.
448pub struct BusinessDayCalculatorBuilder {
449    calendar: HolidayCalendar,
450    weekend_days: Option<HashSet<Weekday>>,
451    half_day_policy: HalfDayPolicy,
452    half_days: HashMap<NaiveDate, NaiveTime>,
453    settlement_rules: SettlementRules,
454    month_end_convention: MonthEndConvention,
455}
456
457impl BusinessDayCalculatorBuilder {
458    /// Create a new builder with a holiday calendar.
459    pub fn new(calendar: HolidayCalendar) -> Self {
460        Self {
461            calendar,
462            weekend_days: None,
463            half_day_policy: HalfDayPolicy::default(),
464            half_days: HashMap::new(),
465            settlement_rules: SettlementRules::default(),
466            month_end_convention: MonthEndConvention::default(),
467        }
468    }
469
470    /// Set weekend days (default is Saturday and Sunday).
471    pub fn weekend_days(mut self, days: HashSet<Weekday>) -> Self {
472        self.weekend_days = Some(days);
473        self
474    }
475
476    /// Set to Middle East weekend (Friday and Saturday).
477    pub fn middle_east_weekend(mut self) -> Self {
478        let mut days = HashSet::new();
479        days.insert(Weekday::Fri);
480        days.insert(Weekday::Sat);
481        self.weekend_days = Some(days);
482        self
483    }
484
485    /// Set half-day policy.
486    pub fn half_day_policy(mut self, policy: HalfDayPolicy) -> Self {
487        self.half_day_policy = policy;
488        self
489    }
490
491    /// Add a half-day.
492    pub fn add_half_day(mut self, date: NaiveDate, close_time: NaiveTime) -> Self {
493        self.half_days.insert(date, close_time);
494        self
495    }
496
497    /// Add US stock market half-days for a year.
498    ///
499    /// Typically: day before Independence Day, Black Friday, Christmas Eve
500    pub fn add_us_market_half_days(mut self, year: i32) -> Self {
501        let close_time = NaiveTime::from_hms_opt(13, 0, 0).unwrap();
502
503        // Day before Independence Day (if July 3 is a weekday)
504        let july_3 = NaiveDate::from_ymd_opt(year, 7, 3).unwrap();
505        if !matches!(july_3.weekday(), Weekday::Sat | Weekday::Sun) {
506            self.half_days.insert(july_3, close_time);
507        }
508
509        // Black Friday (day after Thanksgiving - 4th Thursday of November)
510        let first_nov = NaiveDate::from_ymd_opt(year, 11, 1).unwrap();
511        let days_until_thu = (Weekday::Thu.num_days_from_monday() as i32
512            - first_nov.weekday().num_days_from_monday() as i32
513            + 7)
514            % 7;
515        let thanksgiving = first_nov + Duration::days(days_until_thu as i64 + 21); // 4th Thursday
516        let black_friday = thanksgiving + Duration::days(1);
517        self.half_days.insert(black_friday, close_time);
518
519        // Christmas Eve (if December 24 is a weekday)
520        let christmas_eve = NaiveDate::from_ymd_opt(year, 12, 24).unwrap();
521        if !matches!(christmas_eve.weekday(), Weekday::Sat | Weekday::Sun) {
522            self.half_days.insert(christmas_eve, close_time);
523        }
524
525        self
526    }
527
528    /// Set settlement rules.
529    pub fn settlement_rules(mut self, rules: SettlementRules) -> Self {
530        self.settlement_rules = rules;
531        self
532    }
533
534    /// Set month-end convention.
535    pub fn month_end_convention(mut self, convention: MonthEndConvention) -> Self {
536        self.month_end_convention = convention;
537        self
538    }
539
540    /// Build the BusinessDayCalculator.
541    pub fn build(self) -> BusinessDayCalculator {
542        let mut calc = BusinessDayCalculator::new(self.calendar);
543
544        if let Some(weekend_days) = self.weekend_days {
545            calc.weekend_days = weekend_days;
546        }
547
548        calc.half_day_policy = self.half_day_policy;
549        calc.half_days = self.half_days;
550        calc.settlement_rules = self.settlement_rules;
551        calc.month_end_convention = self.month_end_convention;
552
553        calc
554    }
555}
556
557/// Configuration for business day settings (for YAML/JSON deserialization).
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct BusinessDayConfig {
560    /// Enable business day calculations
561    #[serde(default = "default_true")]
562    pub enabled: bool,
563    /// Half-day policy
564    #[serde(default)]
565    pub half_day_policy: HalfDayPolicy,
566    /// Settlement rules
567    #[serde(default)]
568    pub settlement_rules: SettlementRulesConfig,
569    /// Month-end convention
570    #[serde(default)]
571    pub month_end_convention: MonthEndConvention,
572    /// Weekend days (list of day names like "saturday", "sunday")
573    #[serde(default)]
574    pub weekend_days: Option<Vec<String>>,
575}
576
577fn default_true() -> bool {
578    true
579}
580
581impl Default for BusinessDayConfig {
582    fn default() -> Self {
583        Self {
584            enabled: true,
585            half_day_policy: HalfDayPolicy::default(),
586            settlement_rules: SettlementRulesConfig::default(),
587            month_end_convention: MonthEndConvention::default(),
588            weekend_days: None,
589        }
590    }
591}
592
593/// Settlement rules configuration for YAML/JSON.
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct SettlementRulesConfig {
596    /// Equity settlement days (T+N)
597    #[serde(default = "default_settlement_2")]
598    pub equity_days: i32,
599    /// Government bonds settlement days
600    #[serde(default = "default_settlement_1")]
601    pub government_bonds_days: i32,
602    /// FX spot settlement days
603    #[serde(default = "default_settlement_2")]
604    pub fx_spot_days: i32,
605    /// Corporate bonds settlement days
606    #[serde(default = "default_settlement_2")]
607    pub corporate_bonds_days: i32,
608    /// Wire transfer cutoff time (HH:MM format)
609    #[serde(default = "default_wire_cutoff")]
610    pub wire_cutoff_time: String,
611    /// International wire settlement days
612    #[serde(default = "default_settlement_1")]
613    pub wire_international_days: i32,
614    /// ACH settlement days
615    #[serde(default = "default_settlement_1")]
616    pub ach_days: i32,
617}
618
619fn default_settlement_1() -> i32 {
620    1
621}
622
623fn default_settlement_2() -> i32 {
624    2
625}
626
627fn default_wire_cutoff() -> String {
628    "14:00".to_string()
629}
630
631impl Default for SettlementRulesConfig {
632    fn default() -> Self {
633        Self {
634            equity_days: 2,
635            government_bonds_days: 1,
636            fx_spot_days: 2,
637            corporate_bonds_days: 2,
638            wire_cutoff_time: "14:00".to_string(),
639            wire_international_days: 1,
640            ach_days: 1,
641        }
642    }
643}
644
645impl SettlementRulesConfig {
646    /// Convert to SettlementRules.
647    pub fn to_settlement_rules(&self) -> SettlementRules {
648        let cutoff_time = NaiveTime::parse_from_str(&self.wire_cutoff_time, "%H:%M")
649            .unwrap_or_else(|_| NaiveTime::from_hms_opt(14, 0, 0).unwrap());
650
651        SettlementRules {
652            equity: SettlementType::TPlus(self.equity_days),
653            government_bonds: SettlementType::TPlus(self.government_bonds_days),
654            fx_spot: SettlementType::TPlus(self.fx_spot_days),
655            fx_forward: SettlementType::TPlus(self.fx_spot_days),
656            corporate_bonds: SettlementType::TPlus(self.corporate_bonds_days),
657            wire_domestic: WireSettlementConfig {
658                cutoff_time,
659                before_cutoff: SettlementType::SameDay,
660                after_cutoff: SettlementType::NextBusinessDay,
661            },
662            wire_international: SettlementType::TPlus(self.wire_international_days),
663            ach: SettlementType::TPlus(self.ach_days),
664        }
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671    use crate::distributions::holidays::Region;
672
673    fn test_calendar() -> HolidayCalendar {
674        HolidayCalendar::for_region(Region::US, 2024)
675    }
676
677    #[test]
678    fn test_is_business_day() {
679        let calc = BusinessDayCalculator::new(test_calendar());
680
681        // Regular weekday (Wednesday, no holiday)
682        let wednesday = NaiveDate::from_ymd_opt(2024, 6, 12).unwrap();
683        assert!(calc.is_business_day(wednesday));
684
685        // Weekend (Saturday)
686        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
687        assert!(!calc.is_business_day(saturday));
688
689        // Holiday (Christmas)
690        let christmas = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap();
691        assert!(!calc.is_business_day(christmas));
692    }
693
694    #[test]
695    fn test_add_business_days() {
696        let calc = BusinessDayCalculator::new(test_calendar());
697
698        // Friday + 1 business day = Monday
699        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
700        let next = calc.add_business_days(friday, 1);
701        assert_eq!(next.weekday(), Weekday::Mon);
702        assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
703
704        // Friday June 14 + 5 business days = Monday June 24
705        // (Mon 17, Tue 18, skip Juneteenth Jun 19, Thu 20, Fri 21, Mon 24)
706        let next_week = calc.add_business_days(friday, 5);
707        assert_eq!(next_week.weekday(), Weekday::Mon);
708        assert_eq!(next_week, NaiveDate::from_ymd_opt(2024, 6, 24).unwrap());
709    }
710
711    #[test]
712    fn test_sub_business_days() {
713        let calc = BusinessDayCalculator::new(test_calendar());
714
715        // Monday - 1 business day = Friday
716        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
717        let prev = calc.sub_business_days(monday, 1);
718        assert_eq!(prev.weekday(), Weekday::Fri);
719        assert_eq!(prev, NaiveDate::from_ymd_opt(2024, 6, 14).unwrap());
720    }
721
722    #[test]
723    fn test_business_days_between() {
724        let calc = BusinessDayCalculator::new(test_calendar());
725
726        // Monday to Friday (same week) = 3 business days between (Tue, Wed, Thu)
727        // Note: exclusive of both start and end dates
728        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
729        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
730        assert_eq!(calc.business_days_between(monday, friday), 3);
731
732        // Same day = 0
733        assert_eq!(calc.business_days_between(monday, monday), 0);
734
735        // Reverse direction
736        assert_eq!(calc.business_days_between(friday, monday), -3);
737
738        // Monday to next Monday (skipping weekend) = 4 business days
739        // Tue, Wed, Thu, Fri between Mon-Mon
740        let next_monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
741        assert_eq!(calc.business_days_between(monday, next_monday), 4);
742    }
743
744    #[test]
745    fn test_settlement_t_plus_2() {
746        let calc = BusinessDayCalculator::new(test_calendar());
747
748        // Trade on Monday, settle on Wednesday (T+2)
749        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
750        let settlement = calc.settlement_date(monday, SettlementType::TPlus(2));
751        assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 12).unwrap());
752
753        // Trade on Thursday, settle on Monday (T+2 skips weekend)
754        let thursday = NaiveDate::from_ymd_opt(2024, 6, 13).unwrap();
755        let settlement = calc.settlement_date(thursday, SettlementType::TPlus(2));
756        assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
757    }
758
759    #[test]
760    fn test_settlement_same_day() {
761        let calc = BusinessDayCalculator::new(test_calendar());
762
763        // Business day - same day
764        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
765        assert_eq!(
766            calc.settlement_date(monday, SettlementType::SameDay),
767            monday
768        );
769
770        // Saturday - moves to Monday
771        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
772        let settlement = calc.settlement_date(saturday, SettlementType::SameDay);
773        assert_eq!(settlement, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
774    }
775
776    #[test]
777    fn test_next_business_day() {
778        let calc = BusinessDayCalculator::new(test_calendar());
779
780        // From Saturday, inclusive - moves to Monday
781        let saturday = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
782        let next = calc.next_business_day(saturday, true);
783        assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 17).unwrap());
784
785        // From Monday, inclusive - stays Monday
786        let monday = NaiveDate::from_ymd_opt(2024, 6, 17).unwrap();
787        assert_eq!(calc.next_business_day(monday, true), monday);
788
789        // From Monday, not inclusive - moves to Tuesday
790        assert_eq!(
791            calc.next_business_day(monday, false),
792            NaiveDate::from_ymd_opt(2024, 6, 18).unwrap()
793        );
794    }
795
796    #[test]
797    fn test_prev_business_day() {
798        let calc = BusinessDayCalculator::new(test_calendar());
799
800        // From Sunday, inclusive - moves to Friday
801        let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
802        let prev = calc.prev_business_day(sunday, true);
803        assert_eq!(prev, NaiveDate::from_ymd_opt(2024, 6, 14).unwrap());
804    }
805
806    #[test]
807    fn test_last_business_day_of_month() {
808        let calc = BusinessDayCalculator::new(test_calendar());
809
810        // June 2024 ends on Sunday, so last business day is Friday 28th
811        let june = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
812        let last = calc.last_business_day_of_month(june);
813        assert_eq!(last, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap());
814    }
815
816    #[test]
817    fn test_modified_following_convention() {
818        let calc = BusinessDayCalculator::new(test_calendar())
819            .with_month_end_convention(MonthEndConvention::ModifiedFollowing);
820
821        // June 30, 2024 is a Sunday
822        // Following would give July 1, but that's next month
823        // So Modified Following gives Friday June 28
824        let june_30 = NaiveDate::from_ymd_opt(2024, 6, 30).unwrap();
825        let adjusted = calc.adjust_for_business_day(june_30);
826        assert_eq!(adjusted, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap());
827    }
828
829    #[test]
830    fn test_middle_east_weekend() {
831        let calc = BusinessDayCalculatorBuilder::new(test_calendar())
832            .middle_east_weekend()
833            .build();
834
835        // Friday should be weekend in Middle East
836        let friday = NaiveDate::from_ymd_opt(2024, 6, 14).unwrap();
837        assert!(!calc.is_business_day(friday));
838
839        // Sunday should be a business day
840        let sunday = NaiveDate::from_ymd_opt(2024, 6, 16).unwrap();
841        assert!(calc.is_business_day(sunday));
842    }
843
844    #[test]
845    fn test_half_day_policy() {
846        // Use a date that's not a holiday in the US calendar - July 3, 2024 (Wednesday)
847        let half_day = NaiveDate::from_ymd_opt(2024, 7, 3).unwrap();
848        let close_time = NaiveTime::from_hms_opt(13, 0, 0).unwrap();
849
850        // With HalfDay policy - still a business day
851        let calc_half = BusinessDayCalculatorBuilder::new(test_calendar())
852            .half_day_policy(HalfDayPolicy::HalfDay)
853            .add_half_day(half_day, close_time)
854            .build();
855        assert!(calc_half.is_business_day(half_day));
856        assert_eq!(calc_half.get_half_day_close(half_day), Some(close_time));
857
858        // With NonBusinessDay policy - not a business day
859        let calc_non = BusinessDayCalculatorBuilder::new(test_calendar())
860            .half_day_policy(HalfDayPolicy::NonBusinessDay)
861            .add_half_day(half_day, close_time)
862            .build();
863        assert!(!calc_non.is_business_day(half_day));
864    }
865
866    #[test]
867    fn test_business_days_in_month() {
868        let calc = BusinessDayCalculator::new(test_calendar());
869
870        // June 2024 has 30 days, 8-10 weekend days, and Juneteenth (June 19)
871        // So approximately 20 business days
872        let days = calc.business_days_in_month(2024, 6);
873        assert!(
874            days.len() >= 18 && days.len() <= 22,
875            "Expected 18-22 business days in June 2024, got {}",
876            days.len()
877        );
878
879        // All returned days should be business days
880        for day in &days {
881            assert!(
882                calc.is_business_day(*day),
883                "{} should be a business day",
884                day
885            );
886        }
887    }
888
889    #[test]
890    fn test_wire_settlement() {
891        let calc = BusinessDayCalculator::new(test_calendar());
892        let config = WireSettlementConfig::default();
893
894        let monday = NaiveDate::from_ymd_opt(2024, 6, 10).unwrap();
895
896        // Before cutoff - same day
897        let morning = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
898        assert_eq!(calc.wire_settlement_date(monday, morning, &config), monday);
899
900        // After cutoff - next business day
901        let evening = NaiveTime::from_hms_opt(16, 0, 0).unwrap();
902        let next = calc.wire_settlement_date(monday, evening, &config);
903        assert_eq!(next, NaiveDate::from_ymd_opt(2024, 6, 11).unwrap());
904    }
905
906    #[test]
907    fn test_settlement_rules_config() {
908        let config = SettlementRulesConfig {
909            equity_days: 3,
910            wire_cutoff_time: "15:30".to_string(),
911            ..Default::default()
912        };
913
914        let rules = config.to_settlement_rules();
915        assert_eq!(rules.equity, SettlementType::TPlus(3));
916        assert_eq!(
917            rules.wire_domestic.cutoff_time,
918            NaiveTime::from_hms_opt(15, 30, 0).unwrap()
919        );
920    }
921}