Skip to main content

finance_dates/
calendar.rs

1//! Per-exchange / per-region calendars.
2//!
3//! Distinguishes equity, options, futures, FX, bond, and crypto markets, each
4//! of which can have very different holiday calendars and trading hours. The
5//! `calendar_for_exchange` lookup covers every MIC currently exposed by
6//! finance-enums plus a few common futures venues.
7
8use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc};
9use std::collections::BTreeSet;
10use std::sync::Arc;
11
12use crate::holiday::{HolidayRule, Weekday, WeekendRoll};
13use crate::range::STANDARD_WEEKMASK;
14use crate::trading_hours::{ExtendedSession, Session, TradingHours};
15
16pub use finance_enums::data::{
17    AgricultureType_VARIANTS as AGRICULTURE_TYPES, CommodityType_VARIANTS as COMMODITY_TYPES,
18    CountryCode3_VARIANTS as COUNTRY_CODES3, CountryCode_VARIANTS as COUNTRY_CODES,
19    EnergyType_VARIANTS as ENERGY_TYPES, ExchangeCode_VARIANTS as EXCHANGE_CODES,
20    MarketType_VARIANTS as MARKET_TYPES, MetalsType_VARIANTS as METALS_TYPES,
21    UnderlyingAssetClass_VARIANTS as UNDERLYING_ASSET_CLASSES,
22};
23
24/// Mon-Sun all-true weekmask (used by 24x7 crypto venues).
25pub const CRYPTO_WEEKMASK: [bool; 7] = [true, true, true, true, true, true, true];
26
27/// Sun-Fri weekmask used by 24x5 FX. Monday=index 0; Sunday=index 6.
28pub const FX_WEEKMASK: [bool; 7] = [true, true, true, true, true, false, true];
29
30const MARKET_TYPE_ENUM: &str = "MarketType";
31
32fn finance_enum_variant(
33    enum_name: &str,
34    variants: &'static [&'static str],
35    variant: &str,
36) -> &'static str {
37    variants
38        .iter()
39        .copied()
40        .find(|candidate| *candidate == variant)
41        .unwrap_or_else(|| panic!("finance-enums missing {enum_name}.{variant}"))
42}
43
44fn market_type(variant: &str) -> &'static str {
45    finance_enum_variant(MARKET_TYPE_ENUM, MARKET_TYPES, variant)
46}
47
48/// Date-effective schedule data for a calendar family.
49#[derive(Clone, Debug)]
50pub struct CalendarSchedule {
51    pub effective_start: NaiveDate,
52    pub weekmask: [bool; 7],
53    pub rules: Vec<HolidayRule>,
54    pub trading_hours: Option<TradingHours>,
55}
56
57impl CalendarSchedule {
58    pub fn new(
59        effective_start: NaiveDate,
60        weekmask: [bool; 7],
61        rules: Vec<HolidayRule>,
62        trading_hours: Option<TradingHours>,
63    ) -> Self {
64        Self {
65            effective_start,
66            weekmask,
67            rules,
68            trading_hours,
69        }
70    }
71}
72
73struct ResolvedSchedule<'a> {
74    weekmask: &'a [bool; 7],
75    rules: &'a [HolidayRule],
76    trading_hours: Option<&'a TradingHours>,
77}
78
79/// A holiday calendar with optional trading hours and a market classification.
80pub struct Calendar {
81    pub name: String,
82    /// One of the `MARKET_TYPES` entries, aligned with `finance-enums` `MarketType` variants.
83    pub market_type: &'static str,
84    pub weekmask: [bool; 7],
85    pub rules: Vec<HolidayRule>,
86    pub trading_hours: Option<TradingHours>,
87    pub schedules: Vec<CalendarSchedule>,
88    /// Days when the venue closes earlier than usual. Each rule resolves to
89    /// at most one date per year, paired with a local close time that
90    /// replaces the normal session close on that date.
91    pub early_closes: Vec<EarlyCloseRule>,
92    cache: HolidayCache,
93    early_cache: EarlyCloseCache,
94}
95
96/// An early-close rule. `rule` resolves to a date (using the same machinery
97/// as holiday rules); `close_time` is the local time at which the venue
98/// closes on that date instead of its regular session close.
99#[derive(Clone, Debug)]
100pub struct EarlyCloseRule {
101    pub rule: HolidayRule,
102    pub close_time: NaiveTime,
103}
104
105#[derive(Default)]
106struct EarlyCloseCache {
107    inner: parking_lot_dummy::RwLock<
108        std::collections::HashMap<i32, Arc<std::collections::HashMap<NaiveDate, NaiveTime>>>,
109    >,
110}
111
112#[derive(Default)]
113struct HolidayCache {
114    inner: parking_lot_dummy::RwLock<std::collections::HashMap<i32, Arc<BTreeSet<NaiveDate>>>>,
115}
116
117mod parking_lot_dummy {
118    use std::sync::RwLock as StdRwLock;
119    pub struct RwLock<T>(pub StdRwLock<T>);
120    impl<T: Default> Default for RwLock<T> {
121        fn default() -> Self {
122            Self(StdRwLock::new(T::default()))
123        }
124    }
125    impl<T> RwLock<T> {
126        pub fn read(&self) -> std::sync::RwLockReadGuard<'_, T> {
127            self.0.read().unwrap()
128        }
129        pub fn write(&self) -> std::sync::RwLockWriteGuard<'_, T> {
130            self.0.write().unwrap()
131        }
132    }
133}
134
135impl Calendar {
136    pub fn new(
137        name: impl Into<String>,
138        weekmask: [bool; 7],
139        rules: Vec<HolidayRule>,
140        trading_hours: Option<TradingHours>,
141    ) -> Self {
142        Self::with_type(
143            name,
144            market_type("Equities"),
145            weekmask,
146            rules,
147            trading_hours,
148        )
149    }
150
151    pub fn with_type(
152        name: impl Into<String>,
153        market_type: &'static str,
154        weekmask: [bool; 7],
155        rules: Vec<HolidayRule>,
156        trading_hours: Option<TradingHours>,
157    ) -> Self {
158        Self {
159            name: name.into(),
160            market_type,
161            weekmask,
162            rules,
163            trading_hours,
164            schedules: Vec::new(),
165            early_closes: Vec::new(),
166            cache: HolidayCache::default(),
167            early_cache: EarlyCloseCache::default(),
168        }
169    }
170
171    /// Builder: attach early-close rules.
172    pub fn with_early_closes(mut self, ec: Vec<EarlyCloseRule>) -> Self {
173        self.early_closes = ec;
174        self
175    }
176
177    /// Builder: attach date-effective schedules sorted by effective date.
178    pub fn with_schedules(mut self, mut schedules: Vec<CalendarSchedule>) -> Self {
179        schedules.sort_by_key(|s| s.effective_start);
180        self.schedules = schedules;
181        self.cache = HolidayCache::default();
182        self.early_cache = EarlyCloseCache::default();
183        self
184    }
185
186    fn schedule_for(&self, date: NaiveDate) -> ResolvedSchedule<'_> {
187        let mut selected = None;
188        for schedule in &self.schedules {
189            if schedule.effective_start <= date {
190                selected = Some(schedule);
191            } else {
192                break;
193            }
194        }
195        match selected {
196            Some(schedule) => ResolvedSchedule {
197                weekmask: &schedule.weekmask,
198                rules: &schedule.rules,
199                trading_hours: schedule.trading_hours.as_ref(),
200            },
201            None => ResolvedSchedule {
202                weekmask: &self.weekmask,
203                rules: &self.rules,
204                trading_hours: self.trading_hours.as_ref(),
205            },
206        }
207    }
208
209    fn is_holiday_uncached(&self, date: NaiveDate) -> bool {
210        let schedule = self.schedule_for(date);
211        schedule
212            .rules
213            .iter()
214            .flat_map(|rule| rule.dates_in(date.year()))
215            .any(|holiday| holiday == date)
216    }
217
218    /// Cached map of `date -> local early-close time` for the given year.
219    fn early_close_map(&self, year: i32) -> Arc<std::collections::HashMap<NaiveDate, NaiveTime>> {
220        if let Some(m) = self.early_cache.inner.read().get(&year).cloned() {
221            return m;
222        }
223        let mut m = std::collections::HashMap::new();
224        for ec in &self.early_closes {
225            if let Some(d) = ec.rule.observed_in(year) {
226                // Only register if the resulting date is itself a business
227                // day (skip rolled-into-weekend cases).
228                let i = d.weekday().num_days_from_monday() as usize;
229                if !self.schedule_for(d).weekmask[i] {
230                    continue;
231                }
232                if self.holidays(year).contains(&d) {
233                    continue;
234                }
235                m.insert(d, ec.close_time);
236            }
237        }
238        let arc = Arc::new(m);
239        self.early_cache.inner.write().insert(year, arc.clone());
240        arc
241    }
242
243    /// Local early-close time for `date`, if any.
244    pub fn early_close_for(&self, date: NaiveDate) -> Option<NaiveTime> {
245        self.early_close_map(date.year()).get(&date).copied()
246    }
247
248    pub fn holidays(&self, year: i32) -> Arc<BTreeSet<NaiveDate>> {
249        if let Some(h) = self.cache.inner.read().get(&year).cloned() {
250            return h;
251        }
252        let mut set = BTreeSet::new();
253        if let Some(mut d) = NaiveDate::from_ymd_opt(year, 1, 1) {
254            while d.year() == year {
255                if self.is_holiday_uncached(d) {
256                    set.insert(d);
257                }
258                d += Duration::days(1);
259            }
260        }
261        let arc = Arc::new(set);
262        self.cache.inner.write().insert(year, arc.clone());
263        arc
264    }
265
266    pub fn holidays_between(&self, start: NaiveDate, end: NaiveDate) -> BTreeSet<NaiveDate> {
267        let mut out = BTreeSet::new();
268        for y in start.year()..=end.year() {
269            for d in self.holidays(y).iter() {
270                if *d >= start && *d <= end {
271                    out.insert(*d);
272                }
273            }
274        }
275        out
276    }
277
278    pub fn is_holiday(&self, d: NaiveDate) -> bool {
279        self.holidays(d.year()).contains(&d)
280    }
281
282    pub fn is_business_day(&self, d: NaiveDate) -> bool {
283        let i = d.weekday().num_days_from_monday() as usize;
284        self.schedule_for(d).weekmask[i] && !self.is_holiday(d)
285    }
286
287    pub fn next_business_day(&self, d: NaiveDate) -> NaiveDate {
288        let mut x = d + Duration::days(1);
289        loop {
290            if self.is_business_day(x) {
291                return x;
292            }
293            x += Duration::days(1);
294        }
295    }
296
297    pub fn previous_business_day(&self, d: NaiveDate) -> NaiveDate {
298        let mut x = d - Duration::days(1);
299        loop {
300            if self.is_business_day(x) {
301                return x;
302            }
303            x -= Duration::days(1);
304        }
305    }
306
307    pub fn business_days_between(&self, start: NaiveDate, end: NaiveDate) -> i64 {
308        if end < start {
309            return 0;
310        }
311        let mut n = 0;
312        let mut d = start;
313        while d <= end {
314            if self.is_business_day(d) {
315                n += 1;
316            }
317            d += Duration::days(1);
318        }
319        n
320    }
321
322    pub fn business_day_range(&self, start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> {
323        if end < start {
324            return Vec::new();
325        }
326        let mut out = Vec::with_capacity(((end - start).num_days() as usize).saturating_add(1));
327        let mut d = start;
328        while d <= end {
329            if self.is_business_day(d) {
330                out.push(d);
331            }
332            d += Duration::days(1);
333        }
334        out
335    }
336
337    /// True iff the venue is currently in any trading session.
338    ///
339    /// For sessions that span midnight, the trading-day check considers both
340    /// the local calendar day of `when` and the next local calendar day, so a
341    /// Sun-evening CME open correctly maps to Mon's trading day. If an
342    /// early-close is in effect for that trading day, the last session's
343    /// close is shortened.
344    pub fn is_open(&self, when: DateTime<Utc>) -> bool {
345        let Some(th) = &self.trading_hours else {
346            return false;
347        };
348        let local_today = when.with_timezone(&th.timezone).date_naive();
349        for delta in [0i64, 1] {
350            let trading_day = local_today + Duration::days(delta);
351            if !self.is_business_day(trading_day) {
352                continue;
353            }
354            let Some(th) = self.schedule_for(trading_day).trading_hours else {
355                continue;
356            };
357            let early = self.early_close_for(trading_day);
358            let last_idx = th.sessions.len().saturating_sub(1);
359            for (i, s) in th.sessions.iter().enumerate() {
360                let Some((o, mut c)) = s.instants(th.timezone, trading_day) else {
361                    continue;
362                };
363                if i == last_idx {
364                    if let Some(t) = early {
365                        if let Some(early_c) = adjust_close(th.timezone, trading_day, s, t) {
366                            c = early_c;
367                        }
368                    }
369                }
370                if when >= o && when < c {
371                    return true;
372                }
373            }
374        }
375        false
376    }
377
378    pub fn next_open(&self, when: DateTime<Utc>) -> Option<DateTime<Utc>> {
379        let th = self.trading_hours.as_ref()?;
380        let local_today = when.with_timezone(&th.timezone).date_naive();
381        for delta in 0..400i64 {
382            let trading_day = local_today + Duration::days(delta);
383            if !self.is_business_day(trading_day) {
384                continue;
385            }
386            let Some(th) = self.schedule_for(trading_day).trading_hours else {
387                continue;
388            };
389            for s in &th.sessions {
390                if let Some((o, _)) = s.instants(th.timezone, trading_day) {
391                    if o >= when {
392                        return Some(o);
393                    }
394                }
395            }
396        }
397        None
398    }
399
400    pub fn next_close(&self, when: DateTime<Utc>) -> Option<DateTime<Utc>> {
401        let th = self.trading_hours.as_ref()?;
402        let local_today = when.with_timezone(&th.timezone).date_naive();
403        for delta in 0..400i64 {
404            let trading_day = local_today + Duration::days(delta);
405            if !self.is_business_day(trading_day) {
406                continue;
407            }
408            let Some(th) = self.schedule_for(trading_day).trading_hours else {
409                continue;
410            };
411            let early = self.early_close_for(trading_day);
412            let last_idx = th.sessions.len().saturating_sub(1);
413            for (i, s) in th.sessions.iter().enumerate() {
414                let Some((_, mut c)) = s.instants(th.timezone, trading_day) else {
415                    continue;
416                };
417                if i == last_idx {
418                    if let Some(t) = early {
419                        if let Some(early_c) = adjust_close(th.timezone, trading_day, s, t) {
420                            c = early_c;
421                        }
422                    }
423                }
424                if c >= when {
425                    return Some(c);
426                }
427            }
428        }
429        None
430    }
431
432    /// All `(open, close)` UTC instants for every business day in
433    /// `[start, end]` (inclusive). Each business day contributes one entry
434    /// per trading session, with the last session's close adjusted for any
435    /// early-close rule. Returns an empty vector when no trading hours are
436    /// configured.
437    pub fn sessions_between(
438        &self,
439        start: NaiveDate,
440        end: NaiveDate,
441    ) -> Vec<(DateTime<Utc>, DateTime<Utc>)> {
442        let mut out = Vec::new();
443        let mut d = start;
444        while d <= end {
445            if self.is_business_day(d) {
446                let Some(th) = self.schedule_for(d).trading_hours else {
447                    d += Duration::days(1);
448                    continue;
449                };
450                let last_idx = th.sessions.len().saturating_sub(1);
451                let early = self.early_close_for(d);
452                for (i, s) in th.sessions.iter().enumerate() {
453                    let Some((o, mut c)) = s.instants(th.timezone, d) else {
454                        continue;
455                    };
456                    if i == last_idx {
457                        if let Some(t) = early {
458                            if let Some(early_c) = adjust_close(th.timezone, d, s, t) {
459                                c = early_c;
460                            }
461                        }
462                    }
463                    out.push((o, c));
464                }
465            }
466            d += Duration::days(1);
467        }
468        out
469    }
470
471    /// Named non-regular trading windows for every business day in
472    /// `[start, end]` (inclusive), such as pre-open and after-close.
473    pub fn extended_sessions_between(
474        &self,
475        start: NaiveDate,
476        end: NaiveDate,
477    ) -> Vec<(&'static str, DateTime<Utc>, DateTime<Utc>)> {
478        let mut out = Vec::new();
479        let mut d = start;
480        while d <= end {
481            if self.is_business_day(d) {
482                let Some(th) = self.schedule_for(d).trading_hours else {
483                    d += Duration::days(1);
484                    continue;
485                };
486                let early = self.early_close_for(d);
487                for s in &th.extended_sessions {
488                    let Some((mut o, c)) = s.session.instants(th.timezone, d) else {
489                        continue;
490                    };
491                    if s.name == "after_close" {
492                        if let Some(t) = early {
493                            if let Some(early_o) = adjust_open(th.timezone, d, &s.session, t) {
494                                o = early_o;
495                            }
496                        }
497                    }
498                    if o < c {
499                        out.push((s.name, o, c));
500                    }
501                }
502            }
503            d += Duration::days(1);
504        }
505        out
506    }
507}
508
509fn adjust_open(
510    tz: chrono_tz::Tz,
511    trading_day: NaiveDate,
512    session: &Session,
513    local_open_time: NaiveTime,
514) -> Option<DateTime<Utc>> {
515    use chrono::TimeZone;
516    let open_local_day = trading_day + Duration::days(session.open_day_offset as i64);
517    let open = tz
518        .from_local_datetime(&open_local_day.and_time(local_open_time))
519        .single()?;
520    Some(open.with_timezone(&Utc))
521}
522
523/// Recompute the close instant of `session` on `trading_day` using the
524/// override `local_close_time`. The day-offset of the original close is
525/// preserved so cross-midnight sessions still resolve correctly.
526fn adjust_close(
527    tz: chrono_tz::Tz,
528    trading_day: NaiveDate,
529    session: &Session,
530    local_close_time: NaiveTime,
531) -> Option<DateTime<Utc>> {
532    use chrono::TimeZone;
533    let close_local_day = trading_day + Duration::days(session.close_day_offset as i64);
534    let close = tz
535        .from_local_datetime(&close_local_day.and_time(local_close_time))
536        .single()?;
537    Some(close.with_timezone(&Utc))
538}
539
540// Holiday rule constructors
541
542fn fixed(month: u32, day: u32, since_year: Option<i32>) -> HolidayRule {
543    HolidayRule::Fixed {
544        month,
545        day,
546        roll: WeekendRoll::NearestWeekday,
547        since_year,
548    }
549}
550
551fn fixed_no_roll(month: u32, day: u32, since_year: Option<i32>) -> HolidayRule {
552    HolidayRule::Fixed {
553        month,
554        day,
555        roll: WeekendRoll::None,
556        since_year,
557    }
558}
559
560fn nth(month: u32, weekday: Weekday, n: i32) -> HolidayRule {
561    HolidayRule::NthWeekday {
562        month,
563        weekday,
564        n,
565        since_year: None,
566    }
567}
568
569fn easter(offset_days: i32) -> HolidayRule {
570    HolidayRule::EasterOffset {
571        offset_days,
572        since_year: None,
573    }
574}
575
576// Built-in calendars
577
578fn nyse_rules() -> Vec<HolidayRule> {
579    vec![
580        fixed(1, 1, None),
581        nth(1, Weekday::Mon, 3),
582        nth(2, Weekday::Mon, 3),
583        easter(-2),
584        nth(5, Weekday::Mon, -1),
585        fixed(6, 19, Some(2021)),
586        fixed(7, 4, None),
587        nth(9, Weekday::Mon, 1),
588        nth(11, Weekday::Thu, 4),
589        fixed(12, 25, None),
590    ]
591}
592
593fn nyse_trading_hours() -> TradingHours {
594    TradingHours::new(
595        NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
596        NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
597        chrono_tz::America::New_York,
598    )
599    .with_extended_sessions(vec![
600        ExtendedSession::new(
601            "pre_open",
602            Session::regular(
603                NaiveTime::from_hms_opt(4, 0, 0).unwrap(),
604                NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
605            ),
606        ),
607        ExtendedSession::new(
608            "after_close",
609            Session::regular(
610                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
611                NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
612            ),
613        ),
614    ])
615}
616
617/// Listed options use the same holidays as NYSE; market hours run from 09:30
618/// to 16:15 ET (index options often 16:15, single-name 16:00). We pick 16:15
619/// as the default close to maximize coverage.
620fn options_trading_hours() -> TradingHours {
621    TradingHours::new(
622        NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
623        NaiveTime::from_hms_opt(16, 15, 0).unwrap(),
624        chrono_tz::America::New_York,
625    )
626}
627
628/// CME Globex baseline holidays — only days when *all* products close:
629/// New Year's Day, Good Friday, Christmas. Product-specific closures
630/// (Memorial Day, Independence Day, Thanksgiving, …) are typically partial
631/// closes / early closes which this layer does not yet model.
632fn cme_globex_rules() -> Vec<HolidayRule> {
633    vec![fixed(1, 1, None), easter(-2), fixed(12, 25, None)]
634}
635
636/// CME Globex equity-index, FX, fixed-income futures: 17:00 prev — 16:00 today CT.
637fn cme_globex_overnight_hours() -> TradingHours {
638    TradingHours::from_sessions(
639        vec![Session::overnight(
640            NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
641            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
642        )],
643        chrono_tz::America::Chicago,
644    )
645}
646
647/// CME Globex energy & metals: 17:00 prev — 16:00 today CT. The 16:00-17:00
648/// daily maintenance break appears as the gap before the next trading day's
649/// overnight session. CME WTI Crude Oil contract specs page checked 2026-05-25.
650fn cme_globex_energy_hours() -> TradingHours {
651    TradingHours::from_sessions(
652        vec![Session::overnight(
653            NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
654            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
655        )],
656        chrono_tz::America::Chicago,
657    )
658}
659
660/// CBOT grain and oilseed futures: evening session 19:00 prev — 07:45 today CT,
661/// then day session 08:30 — 13:20 CT. This models the common agricultural
662/// futures split rather than the broad CME financial-futures template. CME Corn
663/// contract specs page checked 2026-05-25.
664fn cbot_grain_futures_hours() -> TradingHours {
665    TradingHours::from_sessions(
666        vec![
667            Session {
668                open: NaiveTime::from_hms_opt(19, 0, 0).unwrap(),
669                open_day_offset: -1,
670                close: NaiveTime::from_hms_opt(7, 45, 0).unwrap(),
671                close_day_offset: 0,
672            },
673            Session::regular(
674                NaiveTime::from_hms_opt(8, 30, 0).unwrap(),
675                NaiveTime::from_hms_opt(13, 20, 0).unwrap(),
676            ),
677        ],
678        chrono_tz::America::Chicago,
679    )
680}
681
682/// CME livestock futures (Live Cattle / Feeder Cattle / Lean Hogs):
683/// 08:30-13:05 CT regular trading session. Source: local
684/// `cme/Trading Hours Export.xlsx` row set for LE/GF/HE captured 2026-05-25.
685fn cme_livestock_hours() -> TradingHours {
686    TradingHours::new(
687        NaiveTime::from_hms_opt(8, 30, 0).unwrap(),
688        NaiveTime::from_hms_opt(13, 5, 0).unwrap(),
689        chrono_tz::America::Chicago,
690    )
691}
692
693/// CME lumber futures regular session: 09:00-15:05 CT.
694/// Source: local `cme/Trading Hours Export.xlsx` row for LBR captured 2026-05-25.
695fn cme_lumber_hours() -> TradingHours {
696    TradingHours::new(
697        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
698        NaiveTime::from_hms_opt(15, 5, 0).unwrap(),
699        chrono_tz::America::Chicago,
700    )
701}
702
703/// CBOE Futures Exchange (CFE) — full US holiday set, 08:30–15:15 CT regular.
704fn cfe_rules() -> Vec<HolidayRule> {
705    vec![
706        fixed(1, 1, None),
707        nth(1, Weekday::Mon, 3),
708        nth(2, Weekday::Mon, 3),
709        easter(-2),
710        nth(5, Weekday::Mon, -1),
711        fixed(6, 19, Some(2022)),
712        fixed(7, 4, None),
713        nth(9, Weekday::Mon, 1),
714        nth(11, Weekday::Thu, 4),
715        fixed(12, 25, None),
716    ]
717}
718
719fn cfe_trading_hours() -> TradingHours {
720    TradingHours::new(
721        NaiveTime::from_hms_opt(8, 30, 0).unwrap(),
722        NaiveTime::from_hms_opt(15, 15, 0).unwrap(),
723        chrono_tz::America::Chicago,
724    )
725}
726
727/// ICE US futures (energy, softs): 20:00 prev — 18:00 today ET.
728fn ice_us_rules() -> Vec<HolidayRule> {
729    vec![fixed(1, 1, None), easter(-2), fixed(12, 25, None)]
730}
731
732fn ice_us_hours() -> TradingHours {
733    TradingHours::from_sessions(
734        vec![Session::overnight(
735            NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
736            NaiveTime::from_hms_opt(18, 0, 0).unwrap(),
737        )],
738        chrono_tz::America::New_York,
739    )
740}
741
742/// SIFMA US bond market — recommended fixed-income closures.
743fn sifma_us_rules() -> Vec<HolidayRule> {
744    vec![
745        fixed(1, 1, None),
746        nth(1, Weekday::Mon, 3),
747        nth(2, Weekday::Mon, 3),
748        easter(-2),
749        nth(5, Weekday::Mon, -1),
750        fixed(6, 19, Some(2022)),
751        fixed(7, 4, None),
752        nth(9, Weekday::Mon, 1),
753        nth(10, Weekday::Mon, 2), // Columbus Day
754        fixed(11, 11, None),      // Veterans Day
755        nth(11, Weekday::Thu, 4),
756        fixed(12, 25, None),
757    ]
758}
759
760fn sifma_us_hours() -> TradingHours {
761    TradingHours::new(
762        NaiveTime::from_hms_opt(7, 0, 0).unwrap(),
763        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
764        chrono_tz::America::New_York,
765    )
766}
767
768/// 24x5 spot FX — opens Sun 17:00 NY, closes Fri 17:00 NY. New Year's Day +
769/// Christmas remain closed; otherwise no holidays.
770fn forex_rules() -> Vec<HolidayRule> {
771    vec![fixed(1, 1, None), fixed(12, 25, None)]
772}
773
774/// 24x7 crypto — no holidays.
775fn crypto_rules() -> Vec<HolidayRule> {
776    vec![]
777}
778
779fn lse_rules() -> Vec<HolidayRule> {
780    vec![
781        fixed(1, 1, None),
782        easter(-2),
783        easter(1),
784        nth(5, Weekday::Mon, 1),
785        nth(5, Weekday::Mon, -1),
786        nth(8, Weekday::Mon, -1),
787        fixed(12, 25, None),
788        fixed(12, 26, None),
789    ]
790}
791
792fn lse_trading_hours() -> TradingHours {
793    TradingHours::new(
794        NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
795        NaiveTime::from_hms_opt(16, 30, 0).unwrap(),
796        chrono_tz::Europe::London,
797    )
798}
799
800fn tse_rules() -> Vec<HolidayRule> {
801    vec![
802        fixed_no_roll(1, 1, None),
803        fixed_no_roll(1, 2, None),
804        fixed_no_roll(1, 3, None),
805        nth(1, Weekday::Mon, 2),
806        fixed_no_roll(2, 11, None),
807        fixed_no_roll(2, 23, Some(2020)),
808        fixed_no_roll(4, 29, None),
809        fixed_no_roll(5, 3, None),
810        fixed_no_roll(5, 4, None),
811        fixed_no_roll(5, 5, None),
812        nth(7, Weekday::Mon, 3),
813        fixed_no_roll(8, 11, None),
814        nth(9, Weekday::Mon, 3),
815        nth(10, Weekday::Mon, 2),
816        fixed_no_roll(11, 3, None),
817        fixed_no_roll(11, 23, None),
818        fixed_no_roll(12, 31, None),
819    ]
820}
821
822fn tse_trading_hours_with_afternoon_close(close: NaiveTime) -> TradingHours {
823    TradingHours::from_sessions(
824        vec![
825            Session::regular(
826                NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
827                NaiveTime::from_hms_opt(11, 30, 0).unwrap(),
828            ),
829            Session::regular(NaiveTime::from_hms_opt(12, 30, 0).unwrap(), close),
830        ],
831        chrono_tz::Asia::Tokyo,
832    )
833}
834
835fn tse_trading_hours() -> TradingHours {
836    // JPX domestic stock auction trading since 2024-11-05: morning
837    // 09:00-11:30, afternoon 12:30-15:30. Source checked 2026-05-25.
838    tse_trading_hours_with_afternoon_close(NaiveTime::from_hms_opt(15, 30, 0).unwrap())
839}
840
841fn tse_historical_trading_hours() -> TradingHours {
842    // JPX domestic stock auction trading before the 2024-11-05 close extension.
843    tse_trading_hours_with_afternoon_close(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
844}
845
846fn tse_schedules() -> Vec<CalendarSchedule> {
847    vec![
848        CalendarSchedule::new(
849            NaiveDate::from_ymd_opt(1900, 1, 1).unwrap(),
850            STANDARD_WEEKMASK,
851            tse_rules(),
852            Some(tse_historical_trading_hours()),
853        ),
854        CalendarSchedule::new(
855            NaiveDate::from_ymd_opt(2024, 11, 5).unwrap(),
856            STANDARD_WEEKMASK,
857            tse_rules(),
858            Some(tse_trading_hours()),
859        ),
860    ]
861}
862
863fn hkex_rules() -> Vec<HolidayRule> {
864    let lny: &'static [(i32, u32, u32)] = &[
865        (2020, 1, 27),
866        (2021, 2, 12),
867        (2022, 2, 1),
868        (2023, 1, 23),
869        (2024, 2, 12),
870        (2025, 1, 29),
871        (2026, 2, 17),
872        (2027, 2, 8),
873        (2028, 1, 26),
874        (2029, 2, 13),
875        (2030, 2, 4),
876    ];
877    vec![
878        fixed(1, 1, None),
879        HolidayRule::Tabulated { table: lny },
880        easter(-2),
881        easter(1),
882        fixed(5, 1, None),
883        fixed(7, 1, None),
884        fixed(10, 1, None),
885        fixed(12, 25, None),
886        fixed(12, 26, None),
887    ]
888}
889
890fn hkex_trading_hours() -> TradingHours {
891    // HKEX securities market continuous trading: morning 09:30-12:00,
892    // afternoon 13:00-16:00. Source checked 2026-05-25.
893    TradingHours::from_sessions(
894        vec![
895            Session::regular(
896                NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
897                NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
898            ),
899            Session::regular(
900                NaiveTime::from_hms_opt(13, 0, 0).unwrap(),
901                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
902            ),
903        ],
904        chrono_tz::Asia::Hong_Kong,
905    )
906}
907
908fn sse_rules() -> Vec<HolidayRule> {
909    let lny: &'static [(i32, u32, u32)] = &[
910        (2020, 1, 25),
911        (2021, 2, 12),
912        (2022, 2, 1),
913        (2023, 1, 22),
914        (2024, 2, 10),
915        (2025, 1, 29),
916        (2026, 2, 17),
917        (2027, 2, 6),
918        (2028, 1, 26),
919        (2029, 2, 13),
920        (2030, 2, 3),
921    ];
922    vec![
923        fixed(1, 1, None),
924        HolidayRule::Tabulated { table: lny },
925        fixed(5, 1, None),
926        fixed(10, 1, None),
927        fixed(10, 2, None),
928        fixed(10, 3, None),
929    ]
930}
931
932fn sse_trading_hours() -> TradingHours {
933    // SSE stocks: continuous auction 09:30-11:30 and 13:00-14:57,
934    // followed by the 14:57-15:00 closing call auction; modeled as an
935    // afternoon regular session through 15:00. Source checked 2026-05-25.
936    TradingHours::from_sessions(
937        vec![
938            Session::regular(
939                NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
940                NaiveTime::from_hms_opt(11, 30, 0).unwrap(),
941            ),
942            Session::regular(
943                NaiveTime::from_hms_opt(13, 0, 0).unwrap(),
944                NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
945            ),
946        ],
947        chrono_tz::Asia::Shanghai,
948    )
949}
950
951fn xetra_rules() -> Vec<HolidayRule> {
952    vec![
953        fixed(1, 1, None),
954        easter(-2),
955        easter(1),
956        fixed(5, 1, None),
957        fixed(10, 3, None),
958        fixed(12, 24, None),
959        fixed(12, 25, None),
960        fixed(12, 26, None),
961        fixed(12, 31, None),
962    ]
963}
964
965fn xetra_trading_hours() -> TradingHours {
966    TradingHours::new(
967        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
968        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
969        chrono_tz::Europe::Berlin,
970    )
971}
972
973fn euronext_paris_rules() -> Vec<HolidayRule> {
974    vec![
975        fixed(1, 1, None),
976        easter(-2),
977        easter(1),
978        fixed(5, 1, None),
979        fixed(12, 25, None),
980        fixed(12, 26, None),
981    ]
982}
983
984fn euronext_paris_trading_hours() -> TradingHours {
985    TradingHours::new(
986        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
987        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
988        chrono_tz::Europe::Paris,
989    )
990}
991
992fn tsx_rules() -> Vec<HolidayRule> {
993    vec![
994        fixed(1, 1, None),
995        nth(2, Weekday::Mon, 3),
996        easter(-2),
997        nth(5, Weekday::Mon, -1),
998        fixed(7, 1, None),
999        nth(8, Weekday::Mon, 1),
1000        nth(9, Weekday::Mon, 1),
1001        nth(10, Weekday::Mon, 2),
1002        fixed(12, 25, None),
1003        fixed(12, 26, None),
1004    ]
1005}
1006
1007fn tsx_trading_hours() -> TradingHours {
1008    TradingHours::new(
1009        NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
1010        NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1011        chrono_tz::America::Toronto,
1012    )
1013}
1014
1015fn asx_rules() -> Vec<HolidayRule> {
1016    vec![
1017        fixed(1, 1, None),
1018        fixed(1, 26, None),
1019        easter(-2),
1020        easter(1),
1021        fixed(4, 25, None),
1022        nth(6, Weekday::Mon, 2),
1023        fixed(12, 25, None),
1024        fixed(12, 26, None),
1025    ]
1026}
1027
1028fn asx_trading_hours() -> TradingHours {
1029    TradingHours::new(
1030        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1031        NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
1032        chrono_tz::Australia::Sydney,
1033    )
1034}
1035
1036fn nse_rules() -> Vec<HolidayRule> {
1037    vec![
1038        fixed(1, 26, None),
1039        fixed(8, 15, None),
1040        fixed(10, 2, None),
1041        fixed(12, 25, None),
1042    ]
1043}
1044
1045fn nse_trading_hours() -> TradingHours {
1046    TradingHours::new(
1047        NaiveTime::from_hms_opt(9, 15, 0).unwrap(),
1048        NaiveTime::from_hms_opt(15, 30, 0).unwrap(),
1049        chrono_tz::Asia::Kolkata,
1050    )
1051}
1052
1053// Early-close rule helpers
1054
1055fn ec(rule: HolidayRule, h: u32, m: u32) -> EarlyCloseRule {
1056    EarlyCloseRule {
1057        rule,
1058        close_time: NaiveTime::from_hms_opt(h, m, 0).unwrap(),
1059    }
1060}
1061
1062/// NYSE/NASDAQ early closes (13:00 ET):
1063/// - Day after Thanksgiving (Black Friday)
1064/// - Christmas Eve when it falls on a weekday (Dec 24)
1065/// - July 3 when it falls on a weekday (day before Independence Day)
1066///
1067/// These are "best-effort" rules; the SEC/NYSE may publish ad-hoc deviations.
1068fn nyse_early_closes() -> Vec<EarlyCloseRule> {
1069    // Black Friday is the day after the 4th Thursday of November (i.e.
1070    // Thanksgiving + 1). Tabulated through 2035 — easily extended.
1071    static BLACK_FRIDAY: &[(i32, u32, u32)] = &[
1072        (2020, 11, 27),
1073        (2021, 11, 26),
1074        (2022, 11, 25),
1075        (2023, 11, 24),
1076        (2024, 11, 29),
1077        (2025, 11, 28),
1078        (2026, 11, 27),
1079        (2027, 11, 26),
1080        (2028, 11, 24),
1081        (2029, 11, 23),
1082        (2030, 11, 29),
1083        (2031, 11, 28),
1084        (2032, 11, 26),
1085        (2033, 11, 25),
1086        (2034, 11, 24),
1087        (2035, 11, 23),
1088    ];
1089    vec![
1090        ec(
1091            HolidayRule::Tabulated {
1092                table: BLACK_FRIDAY,
1093            },
1094            13,
1095            0,
1096        ),
1097        ec(fixed_no_roll(12, 24, None), 13, 0),
1098        ec(fixed_no_roll(7, 3, None), 13, 0),
1099    ]
1100}
1101
1102// Additional non-US equity calendars
1103
1104/// Generic European Christian-calendar holidays: NY, Good Friday,
1105/// Easter Monday, May Day, Christmas, Boxing Day. Used as a baseline.
1106fn euro_basic_rules() -> Vec<HolidayRule> {
1107    vec![
1108        fixed(1, 1, None),
1109        easter(-2),
1110        easter(1),
1111        fixed(5, 1, None),
1112        fixed(12, 25, None),
1113        fixed(12, 26, None),
1114    ]
1115}
1116
1117/// Euronext Amsterdam: same hours as Paris/Brussels/Lisbon (09:00–17:30 CET).
1118fn euronext_hours(tz: chrono_tz::Tz) -> TradingHours {
1119    TradingHours::new(
1120        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1121        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
1122        tz,
1123    )
1124}
1125
1126fn xams_rules() -> Vec<HolidayRule> {
1127    // Amsterdam: NY, Good Friday, Easter Mon, King's Day (Apr 27, since 2014),
1128    // Ascension (+39), Whit Monday (+50), Christmas, Boxing Day.
1129    vec![
1130        fixed(1, 1, None),
1131        easter(-2),
1132        easter(1),
1133        fixed_no_roll(4, 27, Some(2014)),
1134        easter(39),
1135        easter(50),
1136        fixed(12, 25, None),
1137        fixed(12, 26, None),
1138    ]
1139}
1140
1141fn xbru_rules() -> Vec<HolidayRule> {
1142    // Brussels: NY, Good Friday, Easter Mon, Labour, Ascension, Whit Mon,
1143    // Christmas, Boxing Day.
1144    vec![
1145        fixed(1, 1, None),
1146        easter(-2),
1147        easter(1),
1148        fixed(5, 1, None),
1149        easter(39),
1150        easter(50),
1151        fixed(12, 25, None),
1152        fixed(12, 26, None),
1153    ]
1154}
1155
1156fn xlis_rules() -> Vec<HolidayRule> {
1157    // Lisbon: subset of euro_basic + Carnival (Easter -47).
1158    let mut r = euro_basic_rules();
1159    r.push(easter(-47));
1160    r
1161}
1162
1163fn xmil_rules() -> Vec<HolidayRule> {
1164    // Borsa Italiana (Milan): NY, Epiphany (Jan 6), Easter Mon, Liberation
1165    // Day (Apr 25), Labour, Republic Day (Jun 2), Assumption (Aug 15),
1166    // All Saints (Nov 1), Immaculate Conception (Dec 8), Christmas, Boxing.
1167    vec![
1168        fixed(1, 1, None),
1169        fixed_no_roll(1, 6, None),
1170        easter(-2),
1171        easter(1),
1172        fixed_no_roll(4, 25, None),
1173        fixed(5, 1, None),
1174        fixed_no_roll(6, 2, None),
1175        fixed_no_roll(8, 15, None),
1176        fixed_no_roll(11, 1, None),
1177        fixed_no_roll(12, 8, None),
1178        fixed(12, 25, None),
1179        fixed(12, 26, None),
1180    ]
1181}
1182
1183fn xmil_hours() -> TradingHours {
1184    TradingHours::new(
1185        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1186        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
1187        chrono_tz::Europe::Rome,
1188    )
1189}
1190
1191fn xmad_rules() -> Vec<HolidayRule> {
1192    // BME Madrid: NY, Epiphany, Good Friday, Easter Mon, Labour,
1193    // Assumption, National Day (Oct 12), All Saints, Constitution (Dec 6),
1194    // Immaculate (Dec 8), Christmas, Boxing.
1195    vec![
1196        fixed(1, 1, None),
1197        fixed_no_roll(1, 6, None),
1198        easter(-2),
1199        easter(1),
1200        fixed(5, 1, None),
1201        fixed_no_roll(8, 15, None),
1202        fixed_no_roll(10, 12, None),
1203        fixed_no_roll(11, 1, None),
1204        fixed_no_roll(12, 6, None),
1205        fixed_no_roll(12, 8, None),
1206        fixed(12, 25, None),
1207        fixed(12, 26, None),
1208    ]
1209}
1210
1211fn xmad_hours() -> TradingHours {
1212    TradingHours::new(
1213        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1214        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
1215        chrono_tz::Europe::Madrid,
1216    )
1217}
1218
1219fn xswx_rules() -> Vec<HolidayRule> {
1220    // SIX Swiss: NY, Berchtold (Jan 2), Good Friday, Easter Mon, Labour,
1221    // Ascension, Whit Mon, Swiss National (Aug 1), Christmas, Boxing.
1222    vec![
1223        fixed(1, 1, None),
1224        fixed_no_roll(1, 2, None),
1225        easter(-2),
1226        easter(1),
1227        fixed(5, 1, None),
1228        easter(39),
1229        easter(50),
1230        fixed_no_roll(8, 1, None),
1231        fixed(12, 25, None),
1232        fixed(12, 26, None),
1233    ]
1234}
1235
1236fn xswx_hours() -> TradingHours {
1237    TradingHours::new(
1238        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1239        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
1240        chrono_tz::Europe::Zurich,
1241    )
1242}
1243
1244fn xosl_rules() -> Vec<HolidayRule> {
1245    // Oslo Børs: NY, Maundy Thu (-3), Good Friday, Easter Mon, Labour,
1246    // Constitution (May 17), Ascension, Whit Mon, Christmas Eve (half),
1247    // Christmas, Boxing, NYE (half).
1248    vec![
1249        fixed(1, 1, None),
1250        easter(-3),
1251        easter(-2),
1252        easter(1),
1253        fixed(5, 1, None),
1254        fixed_no_roll(5, 17, None),
1255        easter(39),
1256        easter(50),
1257        fixed(12, 25, None),
1258        fixed(12, 26, None),
1259    ]
1260}
1261
1262fn xosl_hours() -> TradingHours {
1263    TradingHours::new(
1264        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1265        NaiveTime::from_hms_opt(16, 20, 0).unwrap(),
1266        chrono_tz::Europe::Oslo,
1267    )
1268}
1269
1270fn xsto_rules() -> Vec<HolidayRule> {
1271    // Stockholm OMX: NY, Epiphany, Good Friday, Easter Mon, Labour,
1272    // Ascension, National Day (Jun 6), Midsummer Eve (Fri before Jun 20-26),
1273    // Christmas Eve, Christmas, Boxing, NYE.
1274    vec![
1275        fixed(1, 1, None),
1276        fixed_no_roll(1, 6, None),
1277        easter(-2),
1278        easter(1),
1279        fixed(5, 1, None),
1280        easter(39),
1281        fixed_no_roll(6, 6, None),
1282        fixed_no_roll(12, 24, None),
1283        fixed(12, 25, None),
1284        fixed(12, 26, None),
1285        fixed_no_roll(12, 31, None),
1286    ]
1287}
1288
1289fn xsto_hours() -> TradingHours {
1290    TradingHours::new(
1291        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1292        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
1293        chrono_tz::Europe::Stockholm,
1294    )
1295}
1296
1297fn xhel_rules() -> Vec<HolidayRule> {
1298    // Helsinki: NY, Epiphany, Good Friday, Easter Mon, Labour,
1299    // Ascension, Midsummer Eve (skip), Independence Day (Dec 6),
1300    // Christmas Eve, Christmas, Boxing.
1301    vec![
1302        fixed(1, 1, None),
1303        fixed_no_roll(1, 6, None),
1304        easter(-2),
1305        easter(1),
1306        fixed(5, 1, None),
1307        easter(39),
1308        fixed_no_roll(12, 6, None),
1309        fixed_no_roll(12, 24, None),
1310        fixed(12, 25, None),
1311        fixed(12, 26, None),
1312    ]
1313}
1314
1315fn xhel_hours() -> TradingHours {
1316    TradingHours::new(
1317        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1318        NaiveTime::from_hms_opt(18, 30, 0).unwrap(),
1319        chrono_tz::Europe::Helsinki,
1320    )
1321}
1322
1323fn xcse_rules() -> Vec<HolidayRule> {
1324    // Copenhagen: NY, Maundy Thu, Good Friday, Easter Mon, Great Prayer Day
1325    // (was Easter+26, abolished 2024), Ascension, Constitution (Jun 5),
1326    // Christmas Eve, Christmas, Boxing.
1327    vec![
1328        fixed(1, 1, None),
1329        easter(-3),
1330        easter(-2),
1331        easter(1),
1332        easter(39),
1333        fixed_no_roll(6, 5, None),
1334        fixed_no_roll(12, 24, None),
1335        fixed(12, 25, None),
1336        fixed(12, 26, None),
1337    ]
1338}
1339
1340fn xcse_hours() -> TradingHours {
1341    TradingHours::new(
1342        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1343        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1344        chrono_tz::Europe::Copenhagen,
1345    )
1346}
1347
1348fn xice_rules() -> Vec<HolidayRule> {
1349    // Iceland: NY, Maundy Thu, Good Fri, Easter Mon, First Day of Summer
1350    // (skip), Labour, Ascension, Whit Mon, National Day (Jun 17),
1351    // Commerce Day (skip), Christmas Eve, Christmas, Boxing.
1352    vec![
1353        fixed(1, 1, None),
1354        easter(-3),
1355        easter(-2),
1356        easter(1),
1357        fixed(5, 1, None),
1358        easter(39),
1359        easter(50),
1360        fixed_no_roll(6, 17, None),
1361        fixed_no_roll(12, 24, None),
1362        fixed(12, 25, None),
1363        fixed(12, 26, None),
1364    ]
1365}
1366
1367fn xice_hours() -> TradingHours {
1368    TradingHours::new(
1369        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1370        NaiveTime::from_hms_opt(15, 30, 0).unwrap(),
1371        chrono_tz::Atlantic::Reykjavik,
1372    )
1373}
1374
1375fn xwar_rules() -> Vec<HolidayRule> {
1376    // Warsaw: NY, Epiphany, Easter Mon, Labour, Constitution (May 3),
1377    // Corpus Christi (+60), Assumption, All Saints, Independence (Nov 11),
1378    // Christmas, Boxing.
1379    vec![
1380        fixed(1, 1, None),
1381        fixed_no_roll(1, 6, None),
1382        easter(1),
1383        fixed(5, 1, None),
1384        fixed_no_roll(5, 3, None),
1385        easter(60),
1386        fixed_no_roll(8, 15, None),
1387        fixed_no_roll(11, 1, None),
1388        fixed_no_roll(11, 11, None),
1389        fixed(12, 25, None),
1390        fixed(12, 26, None),
1391    ]
1392}
1393
1394fn xwar_hours() -> TradingHours {
1395    TradingHours::new(
1396        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1397        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1398        chrono_tz::Europe::Warsaw,
1399    )
1400}
1401
1402fn xpra_rules() -> Vec<HolidayRule> {
1403    // Prague: NY, Good Friday, Easter Mon, Labour, Liberation (May 8),
1404    // Ss Cyril & Methodius (Jul 5), Jan Hus (Jul 6), Statehood (Sep 28),
1405    // Independence (Oct 28), Freedom (Nov 17), Christmas Eve, Christmas, Boxing.
1406    vec![
1407        fixed(1, 1, None),
1408        easter(-2),
1409        easter(1),
1410        fixed(5, 1, None),
1411        fixed_no_roll(5, 8, None),
1412        fixed_no_roll(7, 5, None),
1413        fixed_no_roll(7, 6, None),
1414        fixed_no_roll(9, 28, None),
1415        fixed_no_roll(10, 28, None),
1416        fixed_no_roll(11, 17, None),
1417        fixed_no_roll(12, 24, None),
1418        fixed(12, 25, None),
1419        fixed(12, 26, None),
1420    ]
1421}
1422
1423fn xpra_hours() -> TradingHours {
1424    TradingHours::new(
1425        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1426        NaiveTime::from_hms_opt(16, 25, 0).unwrap(),
1427        chrono_tz::Europe::Prague,
1428    )
1429}
1430
1431fn xbud_rules() -> Vec<HolidayRule> {
1432    // Budapest: NY, 1848 Revolution (Mar 15), Good Friday, Easter Mon,
1433    // Labour, Whit Mon, State Foundation (Aug 20), 1956 Revolution (Oct 23),
1434    // All Saints, Christmas, Boxing.
1435    vec![
1436        fixed(1, 1, None),
1437        fixed_no_roll(3, 15, None),
1438        easter(-2),
1439        easter(1),
1440        fixed(5, 1, None),
1441        easter(50),
1442        fixed_no_roll(8, 20, None),
1443        fixed_no_roll(10, 23, None),
1444        fixed_no_roll(11, 1, None),
1445        fixed(12, 25, None),
1446        fixed(12, 26, None),
1447    ]
1448}
1449
1450fn xbud_hours() -> TradingHours {
1451    TradingHours::new(
1452        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1453        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1454        chrono_tz::Europe::Budapest,
1455    )
1456}
1457
1458fn xwbo_rules() -> Vec<HolidayRule> {
1459    // Vienna (Wiener Börse): NY, Good Friday, Easter Mon, Labour,
1460    // Ascension, Whit Mon, Corpus Christi, Assumption, National (Oct 26),
1461    // All Saints, Immaculate (Dec 8), Christmas Eve, Christmas, Boxing.
1462    vec![
1463        fixed(1, 1, None),
1464        easter(-2),
1465        easter(1),
1466        fixed(5, 1, None),
1467        easter(39),
1468        easter(50),
1469        easter(60),
1470        fixed_no_roll(8, 15, None),
1471        fixed_no_roll(10, 26, None),
1472        fixed_no_roll(11, 1, None),
1473        fixed_no_roll(12, 8, None),
1474        fixed_no_roll(12, 24, None),
1475        fixed(12, 25, None),
1476        fixed(12, 26, None),
1477    ]
1478}
1479
1480fn xwbo_hours() -> TradingHours {
1481    TradingHours::new(
1482        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1483        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
1484        chrono_tz::Europe::Vienna,
1485    )
1486}
1487
1488fn xdub_rules() -> Vec<HolidayRule> {
1489    // Euronext Dublin: NY, Saint Patrick (Mar 17), Good Friday, Easter Mon,
1490    // May Day (1st Mon), June Bank (1st Mon), August Bank (1st Mon),
1491    // October Bank (last Mon), Christmas, Boxing.
1492    vec![
1493        fixed(1, 1, None),
1494        fixed(3, 17, None),
1495        easter(-2),
1496        easter(1),
1497        nth(5, Weekday::Mon, 1),
1498        nth(6, Weekday::Mon, 1),
1499        nth(8, Weekday::Mon, 1),
1500        nth(10, Weekday::Mon, -1),
1501        fixed(12, 25, None),
1502        fixed(12, 26, None),
1503    ]
1504}
1505
1506fn xdub_hours() -> TradingHours {
1507    TradingHours::new(
1508        NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
1509        NaiveTime::from_hms_opt(16, 28, 0).unwrap(),
1510        chrono_tz::Europe::Dublin,
1511    )
1512}
1513
1514// Asia / Pacific
1515
1516fn xkrx_rules() -> Vec<HolidayRule> {
1517    // Korea Exchange: tabulated lunar holidays (Seollal, Chuseok). For
1518    // accuracy these are baked in as lookup tables 2020-2030.
1519    let seollal: &'static [(i32, u32, u32)] = &[
1520        (2020, 1, 24),
1521        (2020, 1, 27),
1522        (2021, 2, 11),
1523        (2021, 2, 12),
1524        (2022, 1, 31),
1525        (2022, 2, 1),
1526        (2022, 2, 2),
1527        (2023, 1, 23),
1528        (2023, 1, 24),
1529        (2024, 2, 9),
1530        (2024, 2, 12),
1531        (2025, 1, 28),
1532        (2025, 1, 29),
1533        (2025, 1, 30),
1534        (2026, 2, 16),
1535        (2026, 2, 17),
1536        (2026, 2, 18),
1537    ];
1538    let chuseok: &'static [(i32, u32, u32)] = &[
1539        (2020, 9, 30),
1540        (2020, 10, 1),
1541        (2020, 10, 2),
1542        (2021, 9, 20),
1543        (2021, 9, 21),
1544        (2021, 9, 22),
1545        (2022, 9, 9),
1546        (2022, 9, 12),
1547        (2023, 9, 28),
1548        (2023, 9, 29),
1549        (2024, 9, 16),
1550        (2024, 9, 17),
1551        (2024, 9, 18),
1552        (2025, 10, 6),
1553        (2025, 10, 7),
1554        (2025, 10, 8),
1555        (2026, 9, 24),
1556        (2026, 9, 25),
1557    ];
1558    vec![
1559        fixed(1, 1, None),
1560        HolidayRule::Tabulated { table: seollal },
1561        fixed_no_roll(3, 1, None),  // Independence Movement
1562        fixed_no_roll(5, 5, None),  // Children's Day
1563        fixed_no_roll(6, 6, None),  // Memorial Day
1564        fixed_no_roll(8, 15, None), // Liberation Day
1565        HolidayRule::Tabulated { table: chuseok },
1566        fixed_no_roll(10, 3, None), // National Foundation
1567        fixed_no_roll(10, 9, None), // Hangul Day
1568        fixed(12, 25, None),
1569    ]
1570}
1571
1572fn xkrx_hours() -> TradingHours {
1573    TradingHours::new(
1574        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1575        NaiveTime::from_hms_opt(15, 30, 0).unwrap(),
1576        chrono_tz::Asia::Seoul,
1577    )
1578}
1579
1580fn xses_rules() -> Vec<HolidayRule> {
1581    // Singapore Exchange: NY, Lunar NY (use Shanghai's table), Good Friday,
1582    // Labour, Vesak Day (varies), National Day (Aug 9), Christmas. Vesak
1583    // and others use simplified handling.
1584    let lny: &'static [(i32, u32, u32)] = &[
1585        (2020, 1, 24),
1586        (2021, 2, 12),
1587        (2022, 2, 1),
1588        (2023, 1, 23),
1589        (2024, 2, 12),
1590        (2025, 1, 29),
1591        (2026, 2, 17),
1592    ];
1593    let lny2: &'static [(i32, u32, u32)] = &[
1594        (2020, 1, 27),
1595        (2021, 2, 15),
1596        (2022, 2, 2),
1597        (2023, 1, 24),
1598        (2024, 2, 13),
1599        (2025, 1, 30),
1600        (2026, 2, 18),
1601    ];
1602    vec![
1603        fixed(1, 1, None),
1604        HolidayRule::Tabulated { table: lny },
1605        HolidayRule::Tabulated { table: lny2 },
1606        easter(-2),
1607        fixed(5, 1, None),
1608        fixed(8, 9, None),
1609        fixed(12, 25, None),
1610    ]
1611}
1612
1613fn xses_hours() -> TradingHours {
1614    TradingHours::new(
1615        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1616        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1617        chrono_tz::Asia::Singapore,
1618    )
1619}
1620
1621fn xtai_rules() -> Vec<HolidayRule> {
1622    // Taiwan Stock Exchange: tabulated Lunar NY (5-7 day closure), Children's
1623    // Day (Apr 4), Tomb Sweeping (Apr 5), Dragon Boat, Mid-Autumn, ROC
1624    // National (Oct 10).
1625    let lny: &'static [(i32, u32, u32)] = &[
1626        (2020, 1, 23),
1627        (2021, 2, 8),
1628        (2022, 1, 27),
1629        (2023, 1, 19),
1630        (2024, 2, 5),
1631        (2025, 1, 23),
1632        (2026, 2, 13),
1633    ];
1634    vec![
1635        fixed(1, 1, None),
1636        HolidayRule::Tabulated { table: lny },
1637        fixed_no_roll(2, 28, None), // Peace Memorial
1638        fixed_no_roll(4, 4, None),  // Children's
1639        fixed_no_roll(4, 5, None),  // Tomb Sweeping
1640        fixed(5, 1, None),
1641        fixed_no_roll(10, 10, None), // ROC National
1642    ]
1643}
1644
1645fn xtai_hours() -> TradingHours {
1646    TradingHours::new(
1647        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1648        NaiveTime::from_hms_opt(13, 30, 0).unwrap(),
1649        chrono_tz::Asia::Taipei,
1650    )
1651}
1652
1653fn xbkk_rules() -> Vec<HolidayRule> {
1654    // SET Bangkok: NY, Chakri Day (Apr 6), Songkran (Apr 13-15), Labour,
1655    // Coronation (May 4), Visakha (varies), Asanha (varies), Queen's
1656    // Birthday (Aug 12), King Bhumibol Memorial (Oct 13), Chulalongkorn
1657    // (Oct 23), King's Birthday (Dec 5), Constitution (Dec 10), NYE.
1658    vec![
1659        fixed(1, 1, None),
1660        fixed(4, 6, None),
1661        fixed_no_roll(4, 13, None),
1662        fixed_no_roll(4, 14, None),
1663        fixed_no_roll(4, 15, None),
1664        fixed(5, 1, None),
1665        fixed_no_roll(5, 4, None),
1666        fixed_no_roll(8, 12, None),
1667        fixed(10, 13, None),
1668        fixed(10, 23, None),
1669        fixed(12, 5, None),
1670        fixed(12, 10, None),
1671        fixed_no_roll(12, 31, None),
1672    ]
1673}
1674
1675fn xbkk_hours() -> TradingHours {
1676    TradingHours::new(
1677        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1678        NaiveTime::from_hms_opt(16, 30, 0).unwrap(),
1679        chrono_tz::Asia::Bangkok,
1680    )
1681}
1682
1683fn xkls_rules() -> Vec<HolidayRule> {
1684    // Bursa Malaysia: NY, Lunar NY, Labour, Wesak, Yang di-Pertuan
1685    // Agong's Birthday (1st Mon Jun), National (Aug 31), Malaysia Day
1686    // (Sep 16), Christmas. Eid/Hari Raya are tabulated.
1687    let lny: &'static [(i32, u32, u32)] = &[
1688        (2020, 1, 27),
1689        (2021, 2, 12),
1690        (2022, 2, 1),
1691        (2023, 1, 23),
1692        (2024, 2, 12),
1693        (2025, 1, 29),
1694        (2026, 2, 17),
1695    ];
1696    vec![
1697        fixed(1, 1, None),
1698        HolidayRule::Tabulated { table: lny },
1699        fixed(5, 1, None),
1700        nth(6, Weekday::Mon, 1),
1701        fixed(8, 31, None),
1702        fixed(9, 16, None),
1703        fixed(12, 25, None),
1704    ]
1705}
1706
1707fn xkls_hours() -> TradingHours {
1708    TradingHours::new(
1709        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1710        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1711        chrono_tz::Asia::Kuala_Lumpur,
1712    )
1713}
1714
1715fn xidx_rules() -> Vec<HolidayRule> {
1716    // Indonesia: NY, Lunar NY, Labour, Pancasila (Jun 1), Independence
1717    // (Aug 17), Christmas. Religious dates simplified.
1718    let lny: &'static [(i32, u32, u32)] = &[
1719        (2020, 1, 27),
1720        (2021, 2, 12),
1721        (2022, 2, 1),
1722        (2023, 1, 23),
1723        (2024, 2, 8),
1724        (2025, 1, 29),
1725        (2026, 2, 17),
1726    ];
1727    vec![
1728        fixed(1, 1, None),
1729        HolidayRule::Tabulated { table: lny },
1730        fixed(5, 1, None),
1731        fixed(6, 1, None),
1732        fixed(8, 17, None),
1733        fixed(12, 25, None),
1734    ]
1735}
1736
1737fn xidx_hours() -> TradingHours {
1738    TradingHours::new(
1739        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1740        NaiveTime::from_hms_opt(15, 50, 0).unwrap(),
1741        chrono_tz::Asia::Jakarta,
1742    )
1743}
1744
1745fn xphs_rules() -> Vec<HolidayRule> {
1746    // Philippine Stock Exchange: NY, Maundy Thu, Good Fri, Araw ng Kagitingan
1747    // (Apr 9), Labour, Independence (Jun 12), Ninoy Aquino (Aug 21), National
1748    // Heroes (last Mon Aug), All Saints, Bonifacio (Nov 30), Christmas,
1749    // Rizal Day (Dec 30), NYE.
1750    vec![
1751        fixed(1, 1, None),
1752        easter(-3),
1753        easter(-2),
1754        fixed(4, 9, None),
1755        fixed(5, 1, None),
1756        fixed(6, 12, None),
1757        fixed(8, 21, None),
1758        nth(8, Weekday::Mon, -1),
1759        fixed_no_roll(11, 1, None),
1760        fixed(11, 30, None),
1761        fixed(12, 25, None),
1762        fixed(12, 30, None),
1763        fixed_no_roll(12, 31, None),
1764    ]
1765}
1766
1767fn xphs_hours() -> TradingHours {
1768    TradingHours::new(
1769        NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
1770        NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
1771        chrono_tz::Asia::Manila,
1772    )
1773}
1774
1775fn xnze_rules() -> Vec<HolidayRule> {
1776    // NZX (New Zealand): NY (Jan 1, Jan 2 observed), Waitangi (Feb 6),
1777    // Good Fri, Easter Mon, ANZAC (Apr 25), King's Birthday (1st Mon Jun),
1778    // Matariki (variable, skipped here), Labour Day (4th Mon Oct),
1779    // Christmas, Boxing Day.
1780    vec![
1781        fixed(1, 1, None),
1782        fixed(1, 2, None),
1783        fixed(2, 6, None),
1784        easter(-2),
1785        easter(1),
1786        fixed(4, 25, None),
1787        nth(6, Weekday::Mon, 1),
1788        nth(10, Weekday::Mon, 4),
1789        fixed(12, 25, None),
1790        fixed(12, 26, None),
1791    ]
1792}
1793
1794fn xnze_hours() -> TradingHours {
1795    TradingHours::new(
1796        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1797        NaiveTime::from_hms_opt(16, 45, 0).unwrap(),
1798        chrono_tz::Pacific::Auckland,
1799    )
1800}
1801
1802// EMEA
1803
1804fn xjse_rules() -> Vec<HolidayRule> {
1805    // Johannesburg: NY, Human Rights (Mar 21), Good Fri, Family Day (Easter
1806    // Mon), Freedom (Apr 27), Workers (May 1), Youth (Jun 16), National
1807    // Women's (Aug 9), Heritage (Sep 24), Day of Reconciliation (Dec 16),
1808    // Christmas, Day of Goodwill (Dec 26).
1809    vec![
1810        fixed(1, 1, None),
1811        fixed(3, 21, None),
1812        easter(-2),
1813        easter(1),
1814        fixed(4, 27, None),
1815        fixed(5, 1, None),
1816        fixed(6, 16, None),
1817        fixed(8, 9, None),
1818        fixed(9, 24, None),
1819        fixed(12, 16, None),
1820        fixed(12, 25, None),
1821        fixed(12, 26, None),
1822    ]
1823}
1824
1825fn xjse_hours() -> TradingHours {
1826    TradingHours::new(
1827        NaiveTime::from_hms_opt(9, 0, 0).unwrap(),
1828        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
1829        chrono_tz::Africa::Johannesburg,
1830    )
1831}
1832
1833/// Sun-Thu weekmask used by Saudi/Gulf venues. Mon=0, Sun=6.
1834const MIDEAST_WEEKMASK: [bool; 7] = [true, true, true, true, false, false, true];
1835
1836fn xsau_rules() -> Vec<HolidayRule> {
1837    // Saudi Tadawul: National Day (Sep 23), Founding Day (Feb 22). Eid
1838    // dates vary by lunar calendar — kept tabulated for accuracy 2020-2026.
1839    let eid_fitr: &'static [(i32, u32, u32)] = &[
1840        (2020, 5, 24),
1841        (2021, 5, 13),
1842        (2022, 5, 2),
1843        (2023, 4, 21),
1844        (2024, 4, 10),
1845        (2025, 3, 30),
1846        (2026, 3, 20),
1847    ];
1848    let eid_adha: &'static [(i32, u32, u32)] = &[
1849        (2020, 7, 31),
1850        (2021, 7, 20),
1851        (2022, 7, 9),
1852        (2023, 6, 28),
1853        (2024, 6, 16),
1854        (2025, 6, 6),
1855        (2026, 5, 27),
1856    ];
1857    vec![
1858        fixed_no_roll(2, 22, Some(2022)),
1859        fixed_no_roll(9, 23, None),
1860        HolidayRule::Tabulated { table: eid_fitr },
1861        HolidayRule::Tabulated { table: eid_adha },
1862    ]
1863}
1864
1865fn xsau_hours() -> TradingHours {
1866    TradingHours::new(
1867        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1868        NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
1869        chrono_tz::Asia::Riyadh,
1870    )
1871}
1872
1873fn xist_rules() -> Vec<HolidayRule> {
1874    // Borsa Istanbul: NY, National Sovereignty (Apr 23), Labour (May 1),
1875    // Commemoration of Atatürk (May 19), Democracy (Jul 15), Victory (Aug 30),
1876    // Republic (Oct 29). Eid dates vary; tabulated.
1877    let eid_fitr: &'static [(i32, u32, u32)] = &[
1878        (2020, 5, 24),
1879        (2021, 5, 13),
1880        (2022, 5, 2),
1881        (2023, 4, 21),
1882        (2024, 4, 10),
1883        (2025, 3, 30),
1884        (2026, 3, 20),
1885    ];
1886    let eid_adha: &'static [(i32, u32, u32)] = &[
1887        (2020, 7, 31),
1888        (2021, 7, 20),
1889        (2022, 7, 9),
1890        (2023, 6, 28),
1891        (2024, 6, 16),
1892        (2025, 6, 6),
1893        (2026, 5, 27),
1894    ];
1895    vec![
1896        fixed(1, 1, None),
1897        fixed(4, 23, None),
1898        fixed(5, 1, None),
1899        fixed(5, 19, None),
1900        fixed(7, 15, None),
1901        fixed(8, 30, None),
1902        fixed(10, 29, None),
1903        HolidayRule::Tabulated { table: eid_fitr },
1904        HolidayRule::Tabulated { table: eid_adha },
1905    ]
1906}
1907
1908fn xist_hours() -> TradingHours {
1909    TradingHours::new(
1910        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
1911        NaiveTime::from_hms_opt(18, 0, 0).unwrap(),
1912        chrono_tz::Europe::Istanbul,
1913    )
1914}
1915
1916/// Sun-Thu weekmask used by TASE.
1917const TASE_WEEKMASK: [bool; 7] = [true, true, true, true, false, false, true];
1918
1919fn xtae_rules() -> Vec<HolidayRule> {
1920    // Tel Aviv Stock Exchange: tabulated Jewish holidays (Passover, Shavuot,
1921    // Rosh Hashanah, Yom Kippur, Sukkot, Simchat Torah, Independence Day).
1922    // Tabulated 2020-2026.
1923    let purim: &'static [(i32, u32, u32)] = &[
1924        (2020, 3, 10),
1925        (2021, 2, 26),
1926        (2022, 3, 17),
1927        (2023, 3, 7),
1928        (2024, 3, 24),
1929        (2025, 3, 14),
1930        (2026, 3, 3),
1931    ];
1932    let passover_eve: &'static [(i32, u32, u32)] = &[
1933        (2020, 4, 8),
1934        (2021, 3, 27),
1935        (2022, 4, 15),
1936        (2023, 4, 5),
1937        (2024, 4, 22),
1938        (2025, 4, 12),
1939        (2026, 4, 1),
1940    ];
1941    let shavuot: &'static [(i32, u32, u32)] = &[
1942        (2020, 5, 29),
1943        (2021, 5, 17),
1944        (2022, 6, 5),
1945        (2023, 5, 26),
1946        (2024, 6, 12),
1947        (2025, 6, 2),
1948        (2026, 5, 22),
1949    ];
1950    let rosh: &'static [(i32, u32, u32)] = &[
1951        (2020, 9, 19),
1952        (2021, 9, 7),
1953        (2022, 9, 26),
1954        (2023, 9, 16),
1955        (2024, 10, 3),
1956        (2025, 9, 23),
1957        (2026, 9, 12),
1958    ];
1959    let yom_kippur: &'static [(i32, u32, u32)] = &[
1960        (2020, 9, 28),
1961        (2021, 9, 16),
1962        (2022, 10, 5),
1963        (2023, 9, 25),
1964        (2024, 10, 12),
1965        (2025, 10, 2),
1966        (2026, 9, 21),
1967    ];
1968    let sukkot: &'static [(i32, u32, u32)] = &[
1969        (2020, 10, 3),
1970        (2021, 9, 21),
1971        (2022, 10, 10),
1972        (2023, 9, 30),
1973        (2024, 10, 17),
1974        (2025, 10, 7),
1975        (2026, 9, 26),
1976    ];
1977    let independence: &'static [(i32, u32, u32)] = &[
1978        (2020, 4, 29),
1979        (2021, 4, 15),
1980        (2022, 5, 5),
1981        (2023, 4, 26),
1982        (2024, 5, 14),
1983        (2025, 5, 1),
1984        (2026, 4, 22),
1985    ];
1986    vec![
1987        HolidayRule::Tabulated { table: purim },
1988        HolidayRule::Tabulated {
1989            table: passover_eve,
1990        },
1991        HolidayRule::Tabulated { table: shavuot },
1992        HolidayRule::Tabulated {
1993            table: independence,
1994        },
1995        HolidayRule::Tabulated { table: rosh },
1996        HolidayRule::Tabulated { table: yom_kippur },
1997        HolidayRule::Tabulated { table: sukkot },
1998    ]
1999}
2000
2001fn xtae_hours() -> TradingHours {
2002    TradingHours::new(
2003        NaiveTime::from_hms_opt(9, 59, 0).unwrap(),
2004        NaiveTime::from_hms_opt(17, 14, 0).unwrap(),
2005        chrono_tz::Asia::Jerusalem,
2006    )
2007}
2008
2009fn xdfm_rules() -> Vec<HolidayRule> {
2010    // Dubai Financial Market / ADX: NY, UAE National (Dec 2-3),
2011    // Commemoration (Nov 30). Eid tabulated.
2012    let eid_fitr: &'static [(i32, u32, u32)] = &[
2013        (2020, 5, 24),
2014        (2021, 5, 13),
2015        (2022, 5, 2),
2016        (2023, 4, 21),
2017        (2024, 4, 10),
2018        (2025, 3, 30),
2019        (2026, 3, 20),
2020    ];
2021    let eid_adha: &'static [(i32, u32, u32)] = &[
2022        (2020, 7, 31),
2023        (2021, 7, 20),
2024        (2022, 7, 9),
2025        (2023, 6, 28),
2026        (2024, 6, 16),
2027        (2025, 6, 6),
2028        (2026, 5, 27),
2029    ];
2030    vec![
2031        fixed(1, 1, None),
2032        fixed(11, 30, None),
2033        fixed(12, 2, None),
2034        fixed(12, 3, None),
2035        HolidayRule::Tabulated { table: eid_fitr },
2036        HolidayRule::Tabulated { table: eid_adha },
2037    ]
2038}
2039
2040fn xdfm_hours() -> TradingHours {
2041    TradingHours::new(
2042        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
2043        NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
2044        chrono_tz::Asia::Dubai,
2045    )
2046}
2047
2048// LatAm
2049
2050fn bvmf_rules() -> Vec<HolidayRule> {
2051    // B3 / BMF Bovespa (São Paulo): NY, Carnival Mon (-48), Carnival Tue (-47),
2052    // Good Friday, Tiradentes (Apr 21), Labour, Corpus Christi (+60),
2053    // Independence (Sep 7), Our Lady of Aparecida (Oct 12), All Souls (Nov 2),
2054    // Republic (Nov 15), Black Awareness (Nov 20), Christmas Eve, Christmas, NYE.
2055    vec![
2056        fixed(1, 1, None),
2057        easter(-48),
2058        easter(-47),
2059        easter(-2),
2060        fixed(4, 21, None),
2061        fixed(5, 1, None),
2062        easter(60),
2063        fixed(9, 7, None),
2064        fixed(10, 12, None),
2065        fixed(11, 2, None),
2066        fixed(11, 15, None),
2067        fixed(11, 20, Some(2024)),
2068        fixed_no_roll(12, 24, None),
2069        fixed(12, 25, None),
2070        fixed_no_roll(12, 31, None),
2071    ]
2072}
2073
2074fn bvmf_hours() -> TradingHours {
2075    TradingHours::new(
2076        NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
2077        NaiveTime::from_hms_opt(17, 30, 0).unwrap(),
2078        chrono_tz::America::Sao_Paulo,
2079    )
2080}
2081
2082fn xmex_rules() -> Vec<HolidayRule> {
2083    // BMV Mexico: NY, Constitution (1st Mon Feb), Benito Juárez (3rd Mon Mar),
2084    // Maundy Thu, Good Fri, Labour, Independence (Sep 16), Revolution
2085    // (3rd Mon Nov), Christmas.
2086    vec![
2087        fixed(1, 1, None),
2088        nth(2, Weekday::Mon, 1),
2089        nth(3, Weekday::Mon, 3),
2090        easter(-3),
2091        easter(-2),
2092        fixed(5, 1, None),
2093        fixed(9, 16, None),
2094        nth(11, Weekday::Mon, 3),
2095        fixed(12, 25, None),
2096    ]
2097}
2098
2099fn xmex_hours() -> TradingHours {
2100    TradingHours::new(
2101        NaiveTime::from_hms_opt(8, 30, 0).unwrap(),
2102        NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
2103        chrono_tz::America::Mexico_City,
2104    )
2105}
2106
2107fn xbue_rules() -> Vec<HolidayRule> {
2108    // Buenos Aires (BYMA): NY, Carnival Mon/Tue, Truth & Justice (Mar 24),
2109    // Malvinas Day (Apr 2), Good Fri, Labour, May Revolution (May 25),
2110    // Flag Day (Jun 20), Independence (Jul 9), San Martín (3rd Mon Aug),
2111    // Diversity (Oct 12), Sovereignty (Nov 20), Immaculate, Christmas.
2112    vec![
2113        fixed(1, 1, None),
2114        easter(-48),
2115        easter(-47),
2116        fixed(3, 24, None),
2117        fixed(4, 2, None),
2118        easter(-2),
2119        fixed(5, 1, None),
2120        fixed(5, 25, None),
2121        fixed(6, 20, None),
2122        fixed(7, 9, None),
2123        nth(8, Weekday::Mon, 3),
2124        fixed(10, 12, None),
2125        fixed(11, 20, None),
2126        fixed(12, 8, None),
2127        fixed(12, 25, None),
2128    ]
2129}
2130
2131fn xbue_hours() -> TradingHours {
2132    TradingHours::new(
2133        NaiveTime::from_hms_opt(11, 0, 0).unwrap(),
2134        NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
2135        chrono_tz::America::Argentina::Buenos_Aires,
2136    )
2137}
2138
2139fn xsgo_rules() -> Vec<HolidayRule> {
2140    // Santiago Stock Exchange: NY, Good Fri, Holy Sat, Labour, Navy Day
2141    // (May 21), Saint Peter & Paul (Jun 29), Virgen del Carmen (Jul 16),
2142    // Assumption, Independence (Sep 18-19), Columbus (Oct 12), Reformation
2143    // (Oct 31), All Saints, Immaculate, Christmas.
2144    vec![
2145        fixed(1, 1, None),
2146        easter(-2),
2147        easter(-1),
2148        fixed(5, 1, None),
2149        fixed(5, 21, None),
2150        fixed(6, 29, None),
2151        fixed(7, 16, None),
2152        fixed(8, 15, None),
2153        fixed(9, 18, None),
2154        fixed(9, 19, None),
2155        fixed(10, 12, None),
2156        fixed(10, 31, None),
2157        fixed(11, 1, None),
2158        fixed(12, 8, None),
2159        fixed(12, 25, None),
2160    ]
2161}
2162
2163fn xsgo_hours() -> TradingHours {
2164    TradingHours::new(
2165        NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
2166        NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
2167        chrono_tz::America::Santiago,
2168    )
2169}
2170
2171fn xlim_rules() -> Vec<HolidayRule> {
2172    // Lima Stock Exchange: NY, Maundy Thu, Good Fri, Labour, Saint Peter
2173    // & Paul, Independence (Jul 28-29), Santa Rosa (Aug 30), Battle of
2174    // Angamos (Oct 8), All Saints, Immaculate, Christmas.
2175    vec![
2176        fixed(1, 1, None),
2177        easter(-3),
2178        easter(-2),
2179        fixed(5, 1, None),
2180        fixed(6, 29, None),
2181        fixed(7, 28, None),
2182        fixed(7, 29, None),
2183        fixed(8, 30, None),
2184        fixed(10, 8, None),
2185        fixed(11, 1, None),
2186        fixed(12, 8, None),
2187        fixed(12, 25, None),
2188    ]
2189}
2190
2191fn xlim_hours() -> TradingHours {
2192    TradingHours::new(
2193        NaiveTime::from_hms_opt(8, 30, 0).unwrap(),
2194        NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
2195        chrono_tz::America::Lima,
2196    )
2197}
2198
2199fn xbog_rules() -> Vec<HolidayRule> {
2200    // Bogotá Stock Exchange (BVC): NY, Epiphany, Saint Joseph, Maundy Thu,
2201    // Good Fri, Labour, Ascension, Corpus Christi, Sacred Heart, Saint
2202    // Peter & Paul, Independence (Jul 20), Battle of Boyacá (Aug 7),
2203    // Assumption, Race Day (Oct 12), All Saints, Independence of Cartagena
2204    // (Nov 11), Immaculate, Christmas.
2205    vec![
2206        fixed(1, 1, None),
2207        fixed(1, 6, None),
2208        fixed(3, 19, None),
2209        easter(-3),
2210        easter(-2),
2211        fixed(5, 1, None),
2212        easter(39),
2213        easter(60),
2214        easter(68),
2215        fixed(7, 20, None),
2216        fixed(8, 7, None),
2217        fixed(8, 15, None),
2218        fixed(10, 12, None),
2219        fixed(11, 1, None),
2220        fixed(11, 11, None),
2221        fixed(12, 8, None),
2222        fixed(12, 25, None),
2223    ]
2224}
2225
2226fn xbog_hours() -> TradingHours {
2227    TradingHours::new(
2228        NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
2229        NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
2230        chrono_tz::America::Bogota,
2231    )
2232}
2233
2234// Calendar family resolver
2235
2236/// Logical calendar family. Many MICs share a family.
2237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2238enum Family {
2239    UsEquity,
2240    UsOptions,
2241    UsBondSifma,
2242    UsFuturesCme,
2243    UsFuturesCmeEnergy,
2244    UsFuturesCbotGrains,
2245    UsFuturesCmeLivestock,
2246    UsFuturesCmeLumber,
2247    UsFuturesIce,
2248    UsFuturesCfe,
2249    Forex24x5,
2250    Crypto24x7,
2251    Lse,
2252    Tse,
2253    Hkex,
2254    Sse,
2255    Xetra,
2256    EuronextParis,
2257    EuronextAms,
2258    EuronextBru,
2259    EuronextLis,
2260    EuronextDub,
2261    Tsx,
2262    Asx,
2263    Nse,
2264    Xmil,
2265    Xmad,
2266    Xswx,
2267    Xosl,
2268    Xsto,
2269    Xhel,
2270    Xcse,
2271    Xice,
2272    Xwar,
2273    Xpra,
2274    Xbud,
2275    Xwbo,
2276    Xkrx,
2277    Xses,
2278    Xtai,
2279    Xbkk,
2280    Xkls,
2281    Xidx,
2282    Xphs,
2283    Xnze,
2284    Xjse,
2285    Xsau,
2286    Xist,
2287    Xtae,
2288    Xdfm,
2289    Bvmf,
2290    Xmex,
2291    Xbue,
2292    Xsgo,
2293    Xlim,
2294    Xbog,
2295}
2296
2297fn family_for_mic(mic: &str) -> Option<Family> {
2298    use Family::*;
2299    let m = match mic {
2300        // US equities
2301        "XNYS" | "NYSD" | "XCIS" | "CISD" | "XCHI" | "ARCX" | "ARCD" | "ARCO"
2302        | "XASE" | "AMXO" | "XNAS" | "XNGS" | "XNCM" | "XNMS" | "NASD" | "XNDQ"
2303        | "XBOS" | "BOSD" | "XBXO" | "XPHL" | "XPSX" | "PSXD" | "XPHO" | "XPBT"
2304        | "XPOR" | "XNFI" | "EDGA" | "EDGD" | "EDGX" | "EDDP" | "EDGO" | "BATS"
2305        | "BZXD" | "BATO" | "BATY" | "BYXD" | "MEMX" | "MEMD" | "IEXG" | "LTSE"
2306        | "MIHI" | "MPRL" | "EPRL" | "EPRD" | "XMIO" | "EMLD"
2307        // Over-the-counter and Financial Industry Regulatory Authority venues share the NYSE holiday calendar
2308        | "OTCM" | "CAVE" | "OTCB" | "OTCQ" | "PINL" | "PINI" | "PINX" | "PSGM"
2309        | "PINC" | "FINR" | "FINN" | "FINC" | "FINY" | "XADF" | "FINO" | "OOTC"
2310        // Synthetic / placeholder venues
2311        | "XXXX" | "PYPR" | "SIMU" => UsEquity,
2312        // US options
2313        "XISE" | "GMNI" | "MCRY" | "XCBO" | "C2OX" | "MXOP" | "OPRA" => UsOptions,
2314        // US futures: CME group equity-index/FX/financials. We also route
2315        // category-level synthetic aliases from CME's Globex filter buckets
2316        // to this baseline template when no product-specific schedule is
2317        // modeled yet.
2318        "XCME" | "FCME" | "GLBX" | "XCBT" | "FCBT" | "XKBT"
2319        // Product-level aliases that use the baseline overnight template.
2320        | "SR3" | "ES" | "NQ" | "RTY"
2321        | "CME_DAIRY" | "GLOBEX_DAIRY" => UsFuturesCme,
2322        // Product-level aliases for livestock daytime sessions.
2323        "LE" | "GF" | "HE" | "CME_LIVESTOCK" | "GLOBEX_LIVESTOCK" => {
2324            UsFuturesCmeLivestock
2325        }
2326        // Product-level aliases for lumber daytime sessions.
2327        "LBR" | "LS" | "CME_LUMBER" | "GLOBEX_LUMBER" => UsFuturesCmeLumber,
2328        // NYMEX (energy/metals) lives under CME group too but with energy hours.
2329        "XNYM" | "NYMEX_ENERGY" | "COMEX_METALS"
2330        // Product-level energy/metals aliases.
2331        | "CL" | "MCL" | "QM" | "GC" | "MGC" | "QO"
2332        | "CME_ENERGY" | "GLOBEX_ENERGY"
2333        | "CME_METALS" | "GLOBEX_METALS" => UsFuturesCmeEnergy,
2334        // Synthetic product-group calendars for materially different CBOT
2335        // agricultural hours.
2336        "CBOT_GRAINS" | "CME_GRAINS" | "GLOBEX_GRAINS"
2337        // Product-level grain/oilseed aliases.
2338        | "ZC" | "ZW" | "ZS" | "ZL" | "ZM" | "ZO" | "KE" | "HRS"
2339        | "CBOT_OILSEEDS" | "CBOT_WHEAT" | "CBOT_CORN" | "CBOT_SOYBEANS" => {
2340            UsFuturesCbotGrains
2341        }
2342        // CFE / ICE / SIFMA / FX / Crypto generic families
2343        "CFE" => UsFuturesCfe,
2344        "ICE_US" => UsFuturesIce,
2345        "SIFMA_US" => UsBondSifma,
2346        "FOREX" => Forex24x5,
2347        "CRYPTO" => Crypto24x7,
2348        // Canada
2349        "XTSE" | "XDRK" | "VDRK" | "XTSX" | "XTNX" | "XATS" | "XATX" | "ADRK"
2350        | "XMOD" | "XMOC" | "NEOE" | "NEOD" | "NEON" | "NEOC" | "XCNQ" | "PURE"
2351        | "CSE2" => Tsx,
2352        // Major non-US equities
2353        "XLON" => Lse,
2354        "XTKS" => Tse,
2355        "XHKG" => Hkex,
2356        "XSHG" => Sse,
2357        "21XX" | "XEUR" | "XFRA" => Xetra,
2358        "XPAR" => EuronextParis,
2359        "XAMS" => EuronextAms,
2360        "XBRU" => EuronextBru,
2361        "XLIS" => EuronextLis,
2362        "XDUB" => EuronextDub,
2363        "XMIL" => Xmil,
2364        "XMAD" => Xmad,
2365        "XSWX" => Xswx,
2366        "XOSL" => Xosl,
2367        "XSTO" => Xsto,
2368        "XHEL" => Xhel,
2369        "XCSE" => Xcse,
2370        "XICE" => Xice,
2371        "XWAR" => Xwar,
2372        "XPRA" => Xpra,
2373        "XBUD" => Xbud,
2374        "XWBO" => Xwbo,
2375        "XASX" => Asx,
2376        "XBOM" | "XNSE" => Nse,
2377        "XKRX" => Xkrx,
2378        "XSES" => Xses,
2379        "XTAI" => Xtai,
2380        "XBKK" => Xbkk,
2381        "XKLS" => Xkls,
2382        "XIDX" => Xidx,
2383        "XPHS" => Xphs,
2384        "XNZE" => Xnze,
2385        "XJSE" => Xjse,
2386        "XSAU" => Xsau,
2387        "XIST" => Xist,
2388        "XTAE" => Xtae,
2389        "XDFM" | "XADS" => Xdfm,
2390        "BVMF" => Bvmf,
2391        "XMEX" => Xmex,
2392        "XBUE" => Xbue,
2393        "XSGO" => Xsgo,
2394        "XLIM" => Xlim,
2395        "XBOG" => Xbog,
2396        _ => return None,
2397    };
2398    Some(m)
2399}
2400
2401fn build_family(name: &str, fam: Family) -> Calendar {
2402    use Family::*;
2403    match fam {
2404        UsEquity => Calendar::with_type(
2405            name,
2406            market_type("Equities"),
2407            STANDARD_WEEKMASK,
2408            nyse_rules(),
2409            Some(nyse_trading_hours()),
2410        )
2411        .with_early_closes(nyse_early_closes()),
2412        UsOptions => Calendar::with_type(
2413            name,
2414            market_type("Options"),
2415            STANDARD_WEEKMASK,
2416            nyse_rules(),
2417            Some(options_trading_hours()),
2418        )
2419        .with_early_closes(nyse_early_closes()),
2420        UsBondSifma => Calendar::with_type(
2421            name,
2422            market_type("FixedIncome"),
2423            STANDARD_WEEKMASK,
2424            sifma_us_rules(),
2425            Some(sifma_us_hours()),
2426        ),
2427        UsFuturesCme => Calendar::with_type(
2428            name,
2429            market_type("Futures"),
2430            STANDARD_WEEKMASK,
2431            cme_globex_rules(),
2432            Some(cme_globex_overnight_hours()),
2433        ),
2434        UsFuturesCmeEnergy => Calendar::with_type(
2435            name,
2436            market_type("Futures"),
2437            STANDARD_WEEKMASK,
2438            cme_globex_rules(),
2439            Some(cme_globex_energy_hours()),
2440        ),
2441        UsFuturesCbotGrains => Calendar::with_type(
2442            name,
2443            market_type("Futures"),
2444            STANDARD_WEEKMASK,
2445            cme_globex_rules(),
2446            Some(cbot_grain_futures_hours()),
2447        ),
2448        UsFuturesCmeLivestock => Calendar::with_type(
2449            name,
2450            market_type("Futures"),
2451            STANDARD_WEEKMASK,
2452            cme_globex_rules(),
2453            Some(cme_livestock_hours()),
2454        ),
2455        UsFuturesCmeLumber => Calendar::with_type(
2456            name,
2457            market_type("Futures"),
2458            STANDARD_WEEKMASK,
2459            cme_globex_rules(),
2460            Some(cme_lumber_hours()),
2461        ),
2462        UsFuturesIce => Calendar::with_type(
2463            name,
2464            market_type("Futures"),
2465            STANDARD_WEEKMASK,
2466            ice_us_rules(),
2467            Some(ice_us_hours()),
2468        ),
2469        UsFuturesCfe => Calendar::with_type(
2470            name,
2471            market_type("Futures"),
2472            STANDARD_WEEKMASK,
2473            cfe_rules(),
2474            Some(cfe_trading_hours()),
2475        ),
2476        Forex24x5 => Calendar::with_type(
2477            name,
2478            market_type("ForeignExchange"),
2479            STANDARD_WEEKMASK,
2480            forex_rules(),
2481            Some(TradingHours::forex_24x5()),
2482        ),
2483        Crypto24x7 => Calendar::with_type(
2484            name,
2485            market_type("DigitalAssets"),
2486            CRYPTO_WEEKMASK,
2487            crypto_rules(),
2488            Some(TradingHours::crypto_24x7()),
2489        ),
2490        Lse => Calendar::with_type(
2491            name,
2492            market_type("Equities"),
2493            STANDARD_WEEKMASK,
2494            lse_rules(),
2495            Some(lse_trading_hours()),
2496        ),
2497        Tse => Calendar::with_type(
2498            name,
2499            market_type("Equities"),
2500            STANDARD_WEEKMASK,
2501            tse_rules(),
2502            Some(tse_trading_hours()),
2503        )
2504        .with_schedules(tse_schedules()),
2505        Hkex => Calendar::with_type(
2506            name,
2507            market_type("Equities"),
2508            STANDARD_WEEKMASK,
2509            hkex_rules(),
2510            Some(hkex_trading_hours()),
2511        ),
2512        Sse => Calendar::with_type(
2513            name,
2514            market_type("Equities"),
2515            STANDARD_WEEKMASK,
2516            sse_rules(),
2517            Some(sse_trading_hours()),
2518        ),
2519        Xetra => Calendar::with_type(
2520            name,
2521            market_type("Equities"),
2522            STANDARD_WEEKMASK,
2523            xetra_rules(),
2524            Some(xetra_trading_hours()),
2525        ),
2526        EuronextParis => Calendar::with_type(
2527            name,
2528            market_type("Equities"),
2529            STANDARD_WEEKMASK,
2530            euronext_paris_rules(),
2531            Some(euronext_paris_trading_hours()),
2532        ),
2533        EuronextAms => Calendar::with_type(
2534            name,
2535            market_type("Equities"),
2536            STANDARD_WEEKMASK,
2537            xams_rules(),
2538            Some(euronext_hours(chrono_tz::Europe::Amsterdam)),
2539        ),
2540        EuronextBru => Calendar::with_type(
2541            name,
2542            market_type("Equities"),
2543            STANDARD_WEEKMASK,
2544            xbru_rules(),
2545            Some(euronext_hours(chrono_tz::Europe::Brussels)),
2546        ),
2547        EuronextLis => Calendar::with_type(
2548            name,
2549            market_type("Equities"),
2550            STANDARD_WEEKMASK,
2551            xlis_rules(),
2552            Some(euronext_hours(chrono_tz::Europe::Lisbon)),
2553        ),
2554        EuronextDub => Calendar::with_type(
2555            name,
2556            market_type("Equities"),
2557            STANDARD_WEEKMASK,
2558            xdub_rules(),
2559            Some(xdub_hours()),
2560        ),
2561        Tsx => Calendar::with_type(
2562            name,
2563            market_type("Equities"),
2564            STANDARD_WEEKMASK,
2565            tsx_rules(),
2566            Some(tsx_trading_hours()),
2567        ),
2568        Asx => Calendar::with_type(
2569            name,
2570            market_type("Equities"),
2571            STANDARD_WEEKMASK,
2572            asx_rules(),
2573            Some(asx_trading_hours()),
2574        ),
2575        Nse => Calendar::with_type(
2576            name,
2577            market_type("Equities"),
2578            STANDARD_WEEKMASK,
2579            nse_rules(),
2580            Some(nse_trading_hours()),
2581        ),
2582        Xmil => Calendar::with_type(
2583            name,
2584            market_type("Equities"),
2585            STANDARD_WEEKMASK,
2586            xmil_rules(),
2587            Some(xmil_hours()),
2588        ),
2589        Xmad => Calendar::with_type(
2590            name,
2591            market_type("Equities"),
2592            STANDARD_WEEKMASK,
2593            xmad_rules(),
2594            Some(xmad_hours()),
2595        ),
2596        Xswx => Calendar::with_type(
2597            name,
2598            market_type("Equities"),
2599            STANDARD_WEEKMASK,
2600            xswx_rules(),
2601            Some(xswx_hours()),
2602        ),
2603        Xosl => Calendar::with_type(
2604            name,
2605            market_type("Equities"),
2606            STANDARD_WEEKMASK,
2607            xosl_rules(),
2608            Some(xosl_hours()),
2609        ),
2610        Xsto => Calendar::with_type(
2611            name,
2612            market_type("Equities"),
2613            STANDARD_WEEKMASK,
2614            xsto_rules(),
2615            Some(xsto_hours()),
2616        ),
2617        Xhel => Calendar::with_type(
2618            name,
2619            market_type("Equities"),
2620            STANDARD_WEEKMASK,
2621            xhel_rules(),
2622            Some(xhel_hours()),
2623        ),
2624        Xcse => Calendar::with_type(
2625            name,
2626            market_type("Equities"),
2627            STANDARD_WEEKMASK,
2628            xcse_rules(),
2629            Some(xcse_hours()),
2630        ),
2631        Xice => Calendar::with_type(
2632            name,
2633            market_type("Equities"),
2634            STANDARD_WEEKMASK,
2635            xice_rules(),
2636            Some(xice_hours()),
2637        ),
2638        Xwar => Calendar::with_type(
2639            name,
2640            market_type("Equities"),
2641            STANDARD_WEEKMASK,
2642            xwar_rules(),
2643            Some(xwar_hours()),
2644        ),
2645        Xpra => Calendar::with_type(
2646            name,
2647            market_type("Equities"),
2648            STANDARD_WEEKMASK,
2649            xpra_rules(),
2650            Some(xpra_hours()),
2651        ),
2652        Xbud => Calendar::with_type(
2653            name,
2654            market_type("Equities"),
2655            STANDARD_WEEKMASK,
2656            xbud_rules(),
2657            Some(xbud_hours()),
2658        ),
2659        Xwbo => Calendar::with_type(
2660            name,
2661            market_type("Equities"),
2662            STANDARD_WEEKMASK,
2663            xwbo_rules(),
2664            Some(xwbo_hours()),
2665        ),
2666        Xkrx => Calendar::with_type(
2667            name,
2668            market_type("Equities"),
2669            STANDARD_WEEKMASK,
2670            xkrx_rules(),
2671            Some(xkrx_hours()),
2672        ),
2673        Xses => Calendar::with_type(
2674            name,
2675            market_type("Equities"),
2676            STANDARD_WEEKMASK,
2677            xses_rules(),
2678            Some(xses_hours()),
2679        ),
2680        Xtai => Calendar::with_type(
2681            name,
2682            market_type("Equities"),
2683            STANDARD_WEEKMASK,
2684            xtai_rules(),
2685            Some(xtai_hours()),
2686        ),
2687        Xbkk => Calendar::with_type(
2688            name,
2689            market_type("Equities"),
2690            STANDARD_WEEKMASK,
2691            xbkk_rules(),
2692            Some(xbkk_hours()),
2693        ),
2694        Xkls => Calendar::with_type(
2695            name,
2696            market_type("Equities"),
2697            STANDARD_WEEKMASK,
2698            xkls_rules(),
2699            Some(xkls_hours()),
2700        ),
2701        Xidx => Calendar::with_type(
2702            name,
2703            market_type("Equities"),
2704            STANDARD_WEEKMASK,
2705            xidx_rules(),
2706            Some(xidx_hours()),
2707        ),
2708        Xphs => Calendar::with_type(
2709            name,
2710            market_type("Equities"),
2711            STANDARD_WEEKMASK,
2712            xphs_rules(),
2713            Some(xphs_hours()),
2714        ),
2715        Xnze => Calendar::with_type(
2716            name,
2717            market_type("Equities"),
2718            STANDARD_WEEKMASK,
2719            xnze_rules(),
2720            Some(xnze_hours()),
2721        ),
2722        Xjse => Calendar::with_type(
2723            name,
2724            market_type("Equities"),
2725            STANDARD_WEEKMASK,
2726            xjse_rules(),
2727            Some(xjse_hours()),
2728        ),
2729        Xsau => Calendar::with_type(
2730            name,
2731            market_type("Equities"),
2732            MIDEAST_WEEKMASK,
2733            xsau_rules(),
2734            Some(xsau_hours()),
2735        ),
2736        Xist => Calendar::with_type(
2737            name,
2738            market_type("Equities"),
2739            STANDARD_WEEKMASK,
2740            xist_rules(),
2741            Some(xist_hours()),
2742        ),
2743        Xtae => Calendar::with_type(
2744            name,
2745            market_type("Equities"),
2746            TASE_WEEKMASK,
2747            xtae_rules(),
2748            Some(xtae_hours()),
2749        ),
2750        Xdfm => Calendar::with_type(
2751            name,
2752            market_type("Equities"),
2753            STANDARD_WEEKMASK,
2754            xdfm_rules(),
2755            Some(xdfm_hours()),
2756        ),
2757        Bvmf => Calendar::with_type(
2758            name,
2759            market_type("Equities"),
2760            STANDARD_WEEKMASK,
2761            bvmf_rules(),
2762            Some(bvmf_hours()),
2763        ),
2764        Xmex => Calendar::with_type(
2765            name,
2766            market_type("Equities"),
2767            STANDARD_WEEKMASK,
2768            xmex_rules(),
2769            Some(xmex_hours()),
2770        ),
2771        Xbue => Calendar::with_type(
2772            name,
2773            market_type("Equities"),
2774            STANDARD_WEEKMASK,
2775            xbue_rules(),
2776            Some(xbue_hours()),
2777        ),
2778        Xsgo => Calendar::with_type(
2779            name,
2780            market_type("Equities"),
2781            STANDARD_WEEKMASK,
2782            xsgo_rules(),
2783            Some(xsgo_hours()),
2784        ),
2785        Xlim => Calendar::with_type(
2786            name,
2787            market_type("Equities"),
2788            STANDARD_WEEKMASK,
2789            xlim_rules(),
2790            Some(xlim_hours()),
2791        ),
2792        Xbog => Calendar::with_type(
2793            name,
2794            market_type("Equities"),
2795            STANDARD_WEEKMASK,
2796            xbog_rules(),
2797            Some(xbog_hours()),
2798        ),
2799    }
2800}
2801
2802/// Build a calendar from its MIC code (or a generic family name like
2803/// `FOREX`, `CRYPTO`, `SIFMA_US`, `ICE_US`, `CFE`). Returns `None` if unknown.
2804pub fn calendar_for_exchange(code: &str) -> Option<Calendar> {
2805    let upper = code.to_ascii_uppercase();
2806    if let Some(fam) = family_for_mic(&upper) {
2807        return Some(build_family(&upper, fam));
2808    }
2809
2810    let record = finance_enums::data::exchange_record(&upper)?;
2811    if let Some(mut calendar) = calendar_for_region(record.iso_country_code) {
2812        calendar.name = upper;
2813        return Some(calendar);
2814    }
2815
2816    Some(Calendar::with_type(
2817        upper,
2818        market_type_for_exchange_record(record),
2819        STANDARD_WEEKMASK,
2820        Vec::new(),
2821        None,
2822    ))
2823}
2824
2825fn market_type_for_exchange_record(record: &finance_enums::data::ExchangeRecord) -> &'static str {
2826    match record.market_category_code {
2827        "IDQS" | "NSPD" | "OTFS" | "SINT" => market_type("OverTheCounter"),
2828        _ => market_type("Equities"),
2829    }
2830}
2831
2832/// Build a calendar from a country code. Returns `None` if unknown.
2833pub fn calendar_for_region(code: &str) -> Option<Calendar> {
2834    let upper = code.to_ascii_uppercase();
2835    if !COUNTRY_CODES.contains(&upper.as_str()) && !COUNTRY_CODES3.contains(&upper.as_str()) {
2836        return None;
2837    }
2838    match upper.as_str() {
2839        "US" | "USA" => calendar_for_exchange("XNYS"),
2840        "GB" | "GBR" => calendar_for_exchange("XLON"),
2841        "JP" | "JPN" => calendar_for_exchange("XTKS"),
2842        "HK" | "HKG" => calendar_for_exchange("XHKG"),
2843        "CN" | "CHN" => calendar_for_exchange("XSHG"),
2844        "DE" | "DEU" => calendar_for_exchange("XFRA"),
2845        "FR" | "FRA" => calendar_for_exchange("XPAR"),
2846        "CA" | "CAN" => calendar_for_exchange("XTSE"),
2847        "AU" | "AUS" => calendar_for_exchange("XASX"),
2848        "IN" | "IND" => calendar_for_exchange("XNSE"),
2849        "NL" | "NLD" => calendar_for_exchange("XAMS"),
2850        "BE" | "BEL" => calendar_for_exchange("XBRU"),
2851        "PT" | "PRT" => calendar_for_exchange("XLIS"),
2852        "IT" | "ITA" => calendar_for_exchange("XMIL"),
2853        "ES" | "ESP" => calendar_for_exchange("XMAD"),
2854        "CH" | "CHE" => calendar_for_exchange("XSWX"),
2855        "NO" | "NOR" => calendar_for_exchange("XOSL"),
2856        "SE" | "SWE" => calendar_for_exchange("XSTO"),
2857        "FI" | "FIN" => calendar_for_exchange("XHEL"),
2858        "DK" | "DNK" => calendar_for_exchange("XCSE"),
2859        "IS" | "ISL" => calendar_for_exchange("XICE"),
2860        "PL" | "POL" => calendar_for_exchange("XWAR"),
2861        "CZ" | "CZE" => calendar_for_exchange("XPRA"),
2862        "HU" | "HUN" => calendar_for_exchange("XBUD"),
2863        "AT" | "AUT" => calendar_for_exchange("XWBO"),
2864        "IE" | "IRL" => calendar_for_exchange("XDUB"),
2865        "KR" | "KOR" => calendar_for_exchange("XKRX"),
2866        "SG" | "SGP" => calendar_for_exchange("XSES"),
2867        "TW" | "TWN" => calendar_for_exchange("XTAI"),
2868        "TH" | "THA" => calendar_for_exchange("XBKK"),
2869        "MY" | "MYS" => calendar_for_exchange("XKLS"),
2870        "ID" | "IDN" => calendar_for_exchange("XIDX"),
2871        "PH" | "PHL" => calendar_for_exchange("XPHS"),
2872        "NZ" | "NZL" => calendar_for_exchange("XNZE"),
2873        "ZA" | "ZAF" => calendar_for_exchange("XJSE"),
2874        "SA" | "SAU" => calendar_for_exchange("XSAU"),
2875        "TR" | "TUR" => calendar_for_exchange("XIST"),
2876        "IL" | "ISR" => calendar_for_exchange("XTAE"),
2877        "AE" | "ARE" => calendar_for_exchange("XDFM"),
2878        "BR" | "BRA" => calendar_for_exchange("BVMF"),
2879        "MX" | "MEX" => calendar_for_exchange("XMEX"),
2880        "AR" | "ARG" => calendar_for_exchange("XBUE"),
2881        "CL" | "CHL" => calendar_for_exchange("XSGO"),
2882        "PE" | "PER" => calendar_for_exchange("XLIM"),
2883        "CO" | "COL" => calendar_for_exchange("XBOG"),
2884        _ => None,
2885    }
2886}
2887
2888/// Build a calendar for a specific product at a given exchange.
2889///
2890/// `exchange` is an exchange MIC (or a synthetic alias such as `FOREX` or
2891/// `CRYPTO`); `product` is a variant name from the `finance-enums`
2892/// commodity/instrument sub-type enums, e.g. `"NaturalGas"`, `"Corn"`,
2893/// `"Gold"`, `"Cattle"`.  When the exchange+product pair matches a
2894/// product-specific schedule the result calendar is named
2895/// `"<EXCHANGE>:<product>"`.  When no product-specific match is found the
2896/// call falls back to `calendar_for_exchange(exchange)`.
2897pub fn calendar_for_product(exchange: &str, product: &str) -> Option<Calendar> {
2898    use Family::*;
2899    let exch = exchange.to_ascii_uppercase();
2900    let fam: Option<Family> = match (exch.as_str(), product) {
2901        // ── NYMEX / COMEX ──────────────────────────────────────────────
2902        // Energy (CL, QM, NG, HO, RB, PRP, UX, etc.)
2903        ("XNYM", "Crude")
2904        | ("XNYM", "NaturalGas")
2905        | ("XNYM", "HeatingOil")
2906        | ("XNYM", "Gasoline")
2907        | ("XNYM", "LiquefiedNaturalGas")
2908        | ("XNYM", "Propane")
2909        | ("XNYM", "Electricity")
2910        | ("XNYM", "Uranium")
2911        | ("XNYM", "Energy") => Some(UsFuturesCmeEnergy),
2912        // Metals (GC, SI, HG, PL, PA, AL…)
2913        ("XNYM", "Gold")
2914        | ("XNYM", "Silver")
2915        | ("XNYM", "Copper")
2916        | ("XNYM", "Platinum")
2917        | ("XNYM", "Palladium")
2918        | ("XNYM", "Aluminum")
2919        | ("XNYM", "Zinc")
2920        | ("XNYM", "Nickel")
2921        | ("XNYM", "Lead")
2922        | ("XNYM", "Tin")
2923        | ("XNYM", "Steel")
2924        | ("XNYM", "Cobalt")
2925        | ("XNYM", "Iron")
2926        | ("XNYM", "Metals") => Some(UsFuturesCmeEnergy),
2927
2928        // ── CBOT grains / oilseeds ─────────────────────────────────────
2929        ("XCBT", "Corn")
2930        | ("XCBT", "Wheat")
2931        | ("XCBT", "Soybean")
2932        | ("XCBT", "Oats")
2933        | ("XCBT", "Soy")
2934        | ("XCBT", "Agriculture")
2935        | ("XCBT", "Softs") => Some(UsFuturesCbotGrains),
2936
2937        // ── CME livestock ──────────────────────────────────────────────
2938        ("XCME", "Cattle") | ("XCME", "Feeder") | ("XCME", "Hogs") | ("XCME", "Livestock") => {
2939            Some(UsFuturesCmeLivestock)
2940        }
2941
2942        // ── CME lumber ─────────────────────────────────────────────────
2943        ("XCME", "Lumber") => Some(UsFuturesCmeLumber),
2944
2945        // ── ICE US (softs + Brent crude) ──────────────────────────────
2946        ("ICE_US", "Sugar")
2947        | ("ICE_US", "Coffee")
2948        | ("ICE_US", "Cocoa")
2949        | ("ICE_US", "Cotton")
2950        | ("ICE_US", "OrangeJuice")
2951        | ("ICE_US", "Crude")
2952        | ("ICE_US", "NaturalGas")
2953        | ("ICE_US", "Energy")
2954        | ("ICE_US", "Softs")
2955        | ("ICE_US", "Agriculture") => Some(UsFuturesIce),
2956
2957        // ── no product-specific override; fall through ─────────────────
2958        _ => None,
2959    };
2960
2961    if let Some(family) = fam {
2962        let name = format!("{}:{}", exch, product);
2963        Some(build_family(&name, family))
2964    } else {
2965        calendar_for_exchange(exchange)
2966    }
2967}
2968
2969fn is_known_asset_label(value: &str) -> bool {
2970    UNDERLYING_ASSET_CLASSES.contains(&value)
2971        || COMMODITY_TYPES.contains(&value)
2972        || ENERGY_TYPES.contains(&value)
2973        || METALS_TYPES.contains(&value)
2974        || AGRICULTURE_TYPES.contains(&value)
2975        || matches!(value, "Feeder")
2976}
2977
2978/// Build a calendar from an exchange and finance-enums asset vocabulary.
2979///
2980/// `asset_class` and optional `subclass` are canonical finance-enums variant
2981/// names, such as `UnderlyingAssetClass::Commodity` plus
2982/// `EnergyType::NaturalGas`, or simply `UnderlyingAssetClass::Equity` for a
2983/// broad exchange calendar. When a subclass is provided it chooses the product
2984/// schedule; otherwise `asset_class` is used directly.
2985pub fn calendar_for_asset(
2986    exchange: &str,
2987    asset_class: &str,
2988    subclass: Option<&str>,
2989) -> Option<Calendar> {
2990    if !is_known_asset_label(asset_class) {
2991        return None;
2992    }
2993    let product = subclass.unwrap_or(asset_class);
2994    if !is_known_asset_label(product) {
2995        return None;
2996    }
2997    calendar_for_product(exchange, product)
2998}
2999
3000#[cfg(test)]
3001mod tests {
3002    use super::*;
3003    use chrono::TimeZone;
3004    use chrono::Timelike;
3005
3006    #[test]
3007    fn nyse_2024_business_day_count() {
3008        let cal = calendar_for_exchange("XNYS").unwrap();
3009        let n = cal.business_days_between(
3010            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
3011            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
3012        );
3013        assert_eq!(n, 252);
3014    }
3015
3016    #[test]
3017    fn nyse_christmas_2022_observed_monday() {
3018        let cal = calendar_for_exchange("XNYS").unwrap();
3019        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2022, 12, 26).unwrap()));
3020    }
3021
3022    #[test]
3023    fn nyse_juneteenth_first_year_2021() {
3024        let cal = calendar_for_exchange("XNYS").unwrap();
3025        assert!(!cal.is_holiday(NaiveDate::from_ymd_opt(2020, 6, 19).unwrap()));
3026        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2021, 6, 18).unwrap()));
3027    }
3028
3029    #[test]
3030    fn lse_easter_monday_2024() {
3031        let cal = calendar_for_exchange("XLON").unwrap();
3032        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 4, 1).unwrap()));
3033    }
3034
3035    #[test]
3036    fn region_us_resolves_to_xnys() {
3037        let cal = calendar_for_region("US").unwrap();
3038        assert_eq!(cal.name, "XNYS");
3039        assert_eq!(calendar_for_region("USA").unwrap().name, "XNYS");
3040        assert!(calendar_for_region("EU").is_none());
3041        assert!(calendar_for_region("UK").is_none());
3042    }
3043
3044    #[test]
3045    fn nyse_is_open_at_market_open() {
3046        let cal = calendar_for_exchange("XNYS").unwrap();
3047        let inst = chrono_tz::America::New_York
3048            .with_ymd_and_hms(2024, 1, 8, 9, 30, 0)
3049            .unwrap()
3050            .with_timezone(&Utc);
3051        assert!(cal.is_open(inst));
3052        let inst_b = chrono_tz::America::New_York
3053            .with_ymd_and_hms(2024, 1, 8, 9, 27, 0)
3054            .unwrap()
3055            .with_timezone(&Utc);
3056        assert!(!cal.is_open(inst_b));
3057    }
3058
3059    #[test]
3060    fn nyse_is_open_handles_dst() {
3061        let cal = calendar_for_exchange("XNYS").unwrap();
3062        let inst = chrono_tz::America::New_York
3063            .with_ymd_and_hms(2024, 3, 11, 9, 30, 0)
3064            .unwrap()
3065            .with_timezone(&Utc);
3066        assert!(cal.is_open(inst));
3067    }
3068
3069    #[test]
3070    fn cme_futures_open_sunday_evening() {
3071        // CME equity-index futures: Sun 18:00 CT should be in Mon's session.
3072        let cal = calendar_for_exchange("XCME").unwrap();
3073        assert_eq!(cal.market_type, market_type("Futures"));
3074        let inst = chrono_tz::America::Chicago
3075            .with_ymd_and_hms(2024, 1, 7, 18, 0, 0)
3076            .unwrap()
3077            .with_timezone(&Utc);
3078        assert!(cal.is_open(inst));
3079        // Sat 03:00 CT — closed.
3080        let inst2 = chrono_tz::America::Chicago
3081            .with_ymd_and_hms(2024, 1, 13, 3, 0, 0)
3082            .unwrap()
3083            .with_timezone(&Utc);
3084        assert!(!cal.is_open(inst2));
3085    }
3086
3087    #[test]
3088    fn nymex_energy_uses_chicago_tz() {
3089        let cal = calendar_for_exchange("XNYM").unwrap();
3090        assert_eq!(cal.market_type, market_type("Futures"));
3091        // Mon 09:00 CT → in session (started Sun 17:00 CT).
3092        let inst = chrono_tz::America::Chicago
3093            .with_ymd_and_hms(2024, 1, 8, 9, 0, 0)
3094            .unwrap()
3095            .with_timezone(&Utc);
3096        assert!(cal.is_open(inst));
3097    }
3098
3099    #[test]
3100    fn nymex_energy_daily_maintenance_break_is_closed() {
3101        let cal = calendar_for_exchange("XNYM").unwrap();
3102        let maintenance_break = chrono_tz::America::Chicago
3103            .with_ymd_and_hms(2024, 1, 8, 16, 30, 0)
3104            .unwrap()
3105            .with_timezone(&Utc);
3106        let next_trade_date_open = chrono_tz::America::Chicago
3107            .with_ymd_and_hms(2024, 1, 8, 17, 0, 0)
3108            .unwrap()
3109            .with_timezone(&Utc);
3110
3111        assert!(!cal.is_open(maintenance_break));
3112        assert_eq!(cal.next_open(maintenance_break), Some(next_trade_date_open));
3113    }
3114
3115    #[test]
3116    fn cbot_grain_futures_expose_overnight_and_day_sessions() {
3117        let cal = calendar_for_exchange("CBOT_GRAINS").unwrap();
3118        assert_eq!(cal.market_type, market_type("Futures"));
3119        let th = cal.trading_hours.as_ref().unwrap();
3120        let actual: Vec<_> = th
3121            .sessions
3122            .iter()
3123            .map(|session| {
3124                (
3125                    (
3126                        session.open.hour(),
3127                        session.open.minute(),
3128                        session.open_day_offset,
3129                    ),
3130                    (
3131                        session.close.hour(),
3132                        session.close.minute(),
3133                        session.close_day_offset,
3134                    ),
3135                )
3136            })
3137            .collect();
3138
3139        assert_eq!(
3140            actual,
3141            vec![((19, 0, -1), (7, 45, 0)), ((8, 30, 0), (13, 20, 0))]
3142        );
3143
3144        let morning_break = chrono_tz::America::Chicago
3145            .with_ymd_and_hms(2024, 1, 8, 8, 0, 0)
3146            .unwrap()
3147            .with_timezone(&Utc);
3148        let day_open = chrono_tz::America::Chicago
3149            .with_ymd_and_hms(2024, 1, 8, 8, 30, 0)
3150            .unwrap()
3151            .with_timezone(&Utc);
3152        let day_close = chrono_tz::America::Chicago
3153            .with_ymd_and_hms(2024, 1, 8, 13, 20, 0)
3154            .unwrap()
3155            .with_timezone(&Utc);
3156
3157        assert!(!cal.is_open(morning_break));
3158        assert_eq!(cal.next_open(morning_break), Some(day_open));
3159        assert_eq!(cal.next_close(morning_break), Some(day_close));
3160    }
3161
3162    #[test]
3163    fn commodity_category_aliases_resolve_to_expected_templates() {
3164        for code in [
3165            "CBOT_OILSEEDS",
3166            "CBOT_WHEAT",
3167            "CBOT_CORN",
3168            "CBOT_SOYBEANS",
3169            "GLOBEX_GRAINS",
3170            "ZC",
3171            "ZW",
3172            "ZS",
3173            "ZL",
3174            "ZM",
3175            "ZO",
3176            "KE",
3177            "HRS",
3178        ] {
3179            let cal = calendar_for_exchange(code).unwrap();
3180            let th = cal.trading_hours.as_ref().unwrap();
3181            assert_eq!(th.sessions.len(), 2, "{code}");
3182            assert_eq!(th.sessions[0].open.hour(), 19, "{code}");
3183            assert_eq!(th.sessions[0].open_day_offset, -1, "{code}");
3184            assert_eq!(th.sessions[1].open.hour(), 8, "{code}");
3185            assert_eq!(th.sessions[1].open.minute(), 30, "{code}");
3186        }
3187
3188        for code in [
3189            "CME_ENERGY",
3190            "GLOBEX_ENERGY",
3191            "CME_METALS",
3192            "GLOBEX_METALS",
3193            "CL",
3194            "MCL",
3195            "QM",
3196            "GC",
3197            "MGC",
3198            "QO",
3199        ] {
3200            let cal = calendar_for_exchange(code).unwrap();
3201            let th = cal.trading_hours.as_ref().unwrap();
3202            assert_eq!(th.sessions.len(), 1, "{code}");
3203            assert_eq!(th.sessions[0].open.hour(), 17, "{code}");
3204            assert_eq!(th.sessions[0].open_day_offset, -1, "{code}");
3205            assert_eq!(th.sessions[0].close.hour(), 16, "{code}");
3206        }
3207
3208        for code in ["CME_DAIRY", "GLOBEX_DAIRY", "SR3", "ES", "NQ", "RTY"] {
3209            let cal = calendar_for_exchange(code).unwrap();
3210            let th = cal.trading_hours.as_ref().unwrap();
3211            assert_eq!(th.sessions.len(), 1, "{code}");
3212            assert_eq!(th.sessions[0].open.hour(), 17, "{code}");
3213            assert_eq!(th.sessions[0].open_day_offset, -1, "{code}");
3214            assert_eq!(th.sessions[0].close.hour(), 16, "{code}");
3215        }
3216
3217        for code in ["CME_LIVESTOCK", "GLOBEX_LIVESTOCK", "LE", "GF", "HE"] {
3218            let cal = calendar_for_exchange(code).unwrap();
3219            let th = cal.trading_hours.as_ref().unwrap();
3220            assert_eq!(th.sessions.len(), 1, "{code}");
3221            assert_eq!(th.sessions[0].open.hour(), 8, "{code}");
3222            assert_eq!(th.sessions[0].open.minute(), 30, "{code}");
3223            assert_eq!(th.sessions[0].open_day_offset, 0, "{code}");
3224            assert_eq!(th.sessions[0].close.hour(), 13, "{code}");
3225            assert_eq!(th.sessions[0].close.minute(), 5, "{code}");
3226        }
3227
3228        for code in ["CME_LUMBER", "GLOBEX_LUMBER", "LBR", "LS"] {
3229            let cal = calendar_for_exchange(code).unwrap();
3230            let th = cal.trading_hours.as_ref().unwrap();
3231            assert_eq!(th.sessions.len(), 1, "{code}");
3232            assert_eq!(th.sessions[0].open.hour(), 9, "{code}");
3233            assert_eq!(th.sessions[0].open_day_offset, 0, "{code}");
3234            assert_eq!(th.sessions[0].close.hour(), 15, "{code}");
3235            assert_eq!(th.sessions[0].close.minute(), 5, "{code}");
3236        }
3237    }
3238
3239    #[test]
3240    fn cfe_classifies_as_futures() {
3241        let cal = calendar_for_exchange("CFE").unwrap();
3242        assert_eq!(cal.market_type, market_type("Futures"));
3243        // Wed 09:00 CT — open.
3244        let inst = chrono_tz::America::Chicago
3245            .with_ymd_and_hms(2024, 1, 10, 9, 0, 0)
3246            .unwrap()
3247            .with_timezone(&Utc);
3248        assert!(cal.is_open(inst));
3249    }
3250
3251    #[test]
3252    fn forex_open_tuesday_3am() {
3253        let cal = calendar_for_exchange("FOREX").unwrap();
3254        assert_eq!(cal.market_type, market_type("ForeignExchange"));
3255        let inst = chrono_tz::America::New_York
3256            .with_ymd_and_hms(2024, 1, 9, 3, 0, 0)
3257            .unwrap()
3258            .with_timezone(&Utc);
3259        assert!(cal.is_open(inst));
3260    }
3261
3262    #[test]
3263    fn crypto_open_saturday_3am() {
3264        let cal = calendar_for_exchange("CRYPTO").unwrap();
3265        assert_eq!(cal.market_type, market_type("DigitalAssets"));
3266        let inst = chrono_tz::UTC
3267            .with_ymd_and_hms(2024, 1, 13, 3, 0, 0)
3268            .unwrap()
3269            .with_timezone(&Utc);
3270        assert!(cal.is_open(inst));
3271    }
3272
3273    #[test]
3274    fn options_close_at_1615() {
3275        let cal = calendar_for_exchange("OPRA").unwrap();
3276        assert_eq!(cal.market_type, market_type("Options"));
3277        let inst = chrono_tz::America::New_York
3278            .with_ymd_and_hms(2024, 1, 8, 16, 10, 0)
3279            .unwrap()
3280            .with_timezone(&Utc);
3281        assert!(cal.is_open(inst));
3282    }
3283
3284    #[test]
3285    fn sifma_includes_columbus_and_veterans() {
3286        let cal = calendar_for_exchange("SIFMA_US").unwrap();
3287        assert_eq!(cal.market_type, market_type("FixedIncome"));
3288        // Veterans Day 2024 = Mon Nov 11.
3289        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 11, 11).unwrap()));
3290        // Columbus Day 2024 = 2nd Mon Oct = Oct 14.
3291        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 10, 14).unwrap()));
3292    }
3293
3294    #[test]
3295    fn ice_us_uses_overnight_session() {
3296        let cal = calendar_for_exchange("ICE_US").unwrap();
3297        // Sun 21:00 NY should be in Mon's ICE session.
3298        let inst = chrono_tz::America::New_York
3299            .with_ymd_and_hms(2024, 1, 7, 21, 0, 0)
3300            .unwrap()
3301            .with_timezone(&Utc);
3302        assert!(cal.is_open(inst));
3303    }
3304
3305    #[test]
3306    fn all_exchange_codes_resolve() {
3307        let mut missing = Vec::new();
3308        for code in EXCHANGE_CODES {
3309            if calendar_for_exchange(code).is_none() {
3310                missing.push(*code);
3311            }
3312        }
3313        assert!(missing.is_empty(), "unresolved MICs: {missing:?}");
3314    }
3315
3316    #[test]
3317    fn exchange_codes_are_sourced_from_finance_enums() {
3318        assert_eq!(EXCHANGE_CODES, finance_enums::data::ExchangeCode_VARIANTS);
3319        assert!(std::ptr::eq(
3320            EXCHANGE_CODES.as_ptr(),
3321            finance_enums::data::ExchangeCode_VARIANTS.as_ptr()
3322        ));
3323    }
3324
3325    #[test]
3326    fn market_type_variants_match_finance_enum_values() {
3327        let expected: &[&str] = &[
3328            "Equities",
3329            "FixedIncome",
3330            "ForeignExchange",
3331            "Commodities",
3332            "Derivatives",
3333            "Options",
3334            "Futures",
3335            "Funds",
3336            "DigitalAssets",
3337            "OverTheCounter",
3338        ];
3339        assert_eq!(MARKET_TYPES, expected);
3340        assert!(!MARKET_TYPES.contains(&"Other"));
3341    }
3342
3343    #[test]
3344    fn market_type_lookup_uses_finance_enum_variant_names() {
3345        assert_eq!(market_type("Options"), "Options");
3346        assert_eq!(market_type("Futures"), "Futures");
3347        assert!(MARKET_TYPES.contains(&market_type("Options")));
3348    }
3349
3350    #[test]
3351    fn calendar_for_asset_uses_finance_enum_asset_names() {
3352        let gas = calendar_for_asset("XNYM", "Commodity", Some("NaturalGas")).unwrap();
3353        assert_eq!(gas.market_type, market_type("Futures"));
3354        assert_eq!(gas.name, "XNYM:NaturalGas");
3355
3356        let grains = calendar_for_asset("XCBT", "Agriculture", None).unwrap();
3357        assert_eq!(grains.market_type, market_type("Futures"));
3358        assert_eq!(grains.name, "XCBT:Agriculture");
3359        assert_eq!(grains.trading_hours.unwrap().sessions.len(), 2);
3360
3361        let equity = calendar_for_asset("XNYS", "Equity", None).unwrap();
3362        assert_eq!(equity.market_type, market_type("Equities"));
3363        assert_eq!(equity.name, "XNYS");
3364
3365        assert!(calendar_for_asset("XNYS", "NotAnAssetClass", None).is_none());
3366    }
3367
3368    #[test]
3369    fn otc_inherits_nyse_holidays() {
3370        let cal = calendar_for_exchange("PINX").unwrap();
3371        assert_eq!(cal.market_type, market_type("Equities"));
3372        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 7, 4).unwrap()));
3373    }
3374
3375    #[test]
3376    fn canadian_calendar_for_neoe() {
3377        let cal = calendar_for_exchange("NEOE").unwrap();
3378        // Canada Day 2024 = Mon Jul 1.
3379        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 7, 1).unwrap()));
3380    }
3381
3382    #[test]
3383    fn nyse_july3_2024_is_early_close() {
3384        // July 4 2024 was Thursday; July 3 (Wed) had a 13:00 ET early close.
3385        let cal = calendar_for_exchange("XNYS").unwrap();
3386        let day = NaiveDate::from_ymd_opt(2024, 7, 3).unwrap();
3387        assert_eq!(
3388            cal.early_close_for(day),
3389            Some(NaiveTime::from_hms_opt(13, 0, 0).unwrap())
3390        );
3391        // 14:00 ET that day should be CLOSED.
3392        let inst = chrono_tz::America::New_York
3393            .with_ymd_and_hms(2024, 7, 3, 14, 0, 0)
3394            .unwrap()
3395            .with_timezone(&Utc);
3396        assert!(!cal.is_open(inst));
3397        // 12:30 ET should be OPEN.
3398        let inst2 = chrono_tz::America::New_York
3399            .with_ymd_and_hms(2024, 7, 3, 12, 30, 0)
3400            .unwrap()
3401            .with_timezone(&Utc);
3402        assert!(cal.is_open(inst2));
3403    }
3404
3405    #[test]
3406    fn nyse_black_friday_2024_early_close() {
3407        // 2024 Thanksgiving = Thu Nov 28; Black Friday = Nov 29.
3408        let cal = calendar_for_exchange("XNYS").unwrap();
3409        assert_eq!(
3410            cal.early_close_for(NaiveDate::from_ymd_opt(2024, 11, 29).unwrap()),
3411            Some(NaiveTime::from_hms_opt(13, 0, 0).unwrap())
3412        );
3413    }
3414
3415    #[test]
3416    fn xams_kingsday_2024() {
3417        // Apr 27 2024 falls on a Saturday; King's Day skipped (no roll).
3418        // Test 2023 instead: Apr 27 2023 = Thursday, holiday.
3419        let cal = calendar_for_exchange("XAMS").unwrap();
3420        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2023, 4, 27).unwrap()));
3421    }
3422
3423    #[test]
3424    fn xkrx_seollal_2024_multi_day() {
3425        // Korean Lunar NY 2024 spans Feb 9 (Fri) and Feb 12 (Mon).
3426        let cal = calendar_for_exchange("XKRX").unwrap();
3427        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 2, 9).unwrap()));
3428        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 2, 12).unwrap()));
3429    }
3430
3431    #[test]
3432    fn xtae_uses_sun_thu_weekmask() {
3433        let cal = calendar_for_exchange("XTAE").unwrap();
3434        // Sun May 5 2024 should be a business day at TASE.
3435        assert!(cal.is_business_day(NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()));
3436        // Fri May 3 2024 — weekend.
3437        assert!(!cal.is_business_day(NaiveDate::from_ymd_opt(2024, 5, 3).unwrap()));
3438    }
3439
3440    #[test]
3441    fn xsau_uses_sun_thu_weekmask() {
3442        let cal = calendar_for_exchange("XSAU").unwrap();
3443        assert!(cal.is_business_day(NaiveDate::from_ymd_opt(2024, 5, 5).unwrap()));
3444        assert!(!cal.is_business_day(NaiveDate::from_ymd_opt(2024, 5, 3).unwrap()));
3445    }
3446
3447    #[test]
3448    fn bvmf_carnival_2024() {
3449        // 2024: Easter Apr 1 → Carnival Mon Feb 12, Tue Feb 13.
3450        let cal = calendar_for_exchange("BVMF").unwrap();
3451        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 2, 12).unwrap()));
3452        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 2, 13).unwrap()));
3453    }
3454
3455    #[test]
3456    fn region_br_resolves_to_bvmf() {
3457        let cal = calendar_for_region("BR").unwrap();
3458        assert_eq!(cal.name, "BVMF");
3459        assert_eq!(calendar_for_region("BRA").unwrap().name, "BVMF");
3460    }
3461
3462    #[test]
3463    fn xnze_waitangi_2024() {
3464        let cal = calendar_for_exchange("XNZE").unwrap();
3465        // Feb 6 2024 = Tuesday.
3466        assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 2, 6).unwrap()));
3467    }
3468
3469    #[test]
3470    fn apac_lunch_break_calendars_expose_split_sessions() {
3471        let cases = [
3472            ("XTKS", vec![((9, 0), (11, 30)), ((12, 30), (15, 30))]),
3473            ("XHKG", vec![((9, 30), (12, 0)), ((13, 0), (16, 0))]),
3474            ("XSHG", vec![((9, 30), (11, 30)), ((13, 0), (15, 0))]),
3475        ];
3476
3477        for (code, expected) in cases {
3478            let cal = calendar_for_exchange(code).unwrap();
3479            let th = cal.trading_hours.as_ref().unwrap();
3480            let actual: Vec<_> = th
3481                .sessions
3482                .iter()
3483                .map(|session| {
3484                    (
3485                        (session.open.hour(), session.open.minute()),
3486                        (session.close.hour(), session.close.minute()),
3487                    )
3488                })
3489                .collect();
3490            assert_eq!(actual, expected, "{code} sessions");
3491        }
3492    }
3493
3494    #[test]
3495    fn tokyo_lunch_gap_is_closed_and_boundaries_advance() {
3496        let cal = calendar_for_exchange("XTKS").unwrap();
3497        let lunch_gap = chrono_tz::Asia::Tokyo
3498            .with_ymd_and_hms(2026, 5, 25, 11, 45, 0)
3499            .unwrap()
3500            .with_timezone(&Utc);
3501        let afternoon_open = chrono_tz::Asia::Tokyo
3502            .with_ymd_and_hms(2026, 5, 25, 12, 30, 0)
3503            .unwrap()
3504            .with_timezone(&Utc);
3505        let afternoon_close = chrono_tz::Asia::Tokyo
3506            .with_ymd_and_hms(2026, 5, 25, 15, 30, 0)
3507            .unwrap()
3508            .with_timezone(&Utc);
3509
3510        assert!(!cal.is_open(lunch_gap));
3511        assert_eq!(cal.next_open(lunch_gap), Some(afternoon_open));
3512        assert_eq!(cal.next_close(lunch_gap), Some(afternoon_close));
3513
3514        let sessions = cal.sessions_between(
3515            NaiveDate::from_ymd_opt(2026, 5, 25).unwrap(),
3516            NaiveDate::from_ymd_opt(2026, 5, 25).unwrap(),
3517        );
3518        assert_eq!(sessions.len(), 2);
3519    }
3520
3521    #[test]
3522    fn tokyo_uses_historical_close_before_2024_schedule_change() {
3523        let cal = calendar_for_exchange("XTKS").unwrap();
3524        let before = cal.sessions_between(
3525            NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(),
3526            NaiveDate::from_ymd_opt(2024, 11, 1).unwrap(),
3527        );
3528        let after = cal.sessions_between(
3529            NaiveDate::from_ymd_opt(2024, 11, 5).unwrap(),
3530            NaiveDate::from_ymd_opt(2024, 11, 5).unwrap(),
3531        );
3532
3533        let before_close = before[1].1.with_timezone(&chrono_tz::Asia::Tokyo);
3534        let after_close = after[1].1.with_timezone(&chrono_tz::Asia::Tokyo);
3535        assert_eq!((before_close.hour(), before_close.minute()), (15, 0));
3536        assert_eq!((after_close.hour(), after_close.minute()), (15, 30));
3537
3538        let old_late_afternoon = chrono_tz::Asia::Tokyo
3539            .with_ymd_and_hms(2024, 11, 1, 15, 15, 0)
3540            .unwrap()
3541            .with_timezone(&Utc);
3542        let current_late_afternoon = chrono_tz::Asia::Tokyo
3543            .with_ymd_and_hms(2024, 11, 5, 15, 15, 0)
3544            .unwrap()
3545            .with_timezone(&Utc);
3546        assert!(!cal.is_open(old_late_afternoon));
3547        assert!(cal.is_open(current_late_afternoon));
3548    }
3549
3550    #[test]
3551    fn session_boundaries_are_explicitly_inclusive_for_next_boundaries() {
3552        let cal = calendar_for_exchange("XTKS").unwrap();
3553        let exact_afternoon_open = chrono_tz::Asia::Tokyo
3554            .with_ymd_and_hms(2026, 5, 25, 12, 30, 0)
3555            .unwrap()
3556            .with_timezone(&Utc);
3557        let exact_morning_close = chrono_tz::Asia::Tokyo
3558            .with_ymd_and_hms(2026, 5, 25, 11, 30, 0)
3559            .unwrap()
3560            .with_timezone(&Utc);
3561
3562        assert!(cal.is_open(exact_afternoon_open));
3563        assert_eq!(
3564            cal.next_open(exact_afternoon_open),
3565            Some(exact_afternoon_open)
3566        );
3567        assert!(!cal.is_open(exact_morning_close));
3568        assert_eq!(
3569            cal.next_close(exact_morning_close),
3570            Some(exact_morning_close)
3571        );
3572    }
3573
3574    #[test]
3575    fn nyse_sessions_between_one_week_with_early_close() {
3576        let cal = calendar_for_exchange("XNYS").unwrap();
3577        // Mon Jul 1 — Fri Jul 5 2024. Jul 4 = holiday; Jul 3 = early close.
3578        let s = cal.sessions_between(
3579            NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
3580            NaiveDate::from_ymd_opt(2024, 7, 5).unwrap(),
3581        );
3582        assert_eq!(s.len(), 4);
3583        // Jul 3 close (3rd entry) should be 13:00 ET = 17:00 UTC (EDT = UTC-4).
3584        let jul3_close_local = s[2].1.with_timezone(&chrono_tz::America::New_York);
3585        assert_eq!(jul3_close_local.hour(), 13);
3586        assert_eq!(jul3_close_local.minute(), 0);
3587        // Jul 5 close (4th entry) should be 16:00 ET (regular).
3588        let jul5_close_local = s[3].1.with_timezone(&chrono_tz::America::New_York);
3589        assert_eq!(jul5_close_local.hour(), 16);
3590    }
3591
3592    #[test]
3593    fn nyse_extended_sessions_include_pre_open_and_after_close() {
3594        let cal = calendar_for_exchange("XNYS").unwrap();
3595        let s = cal.extended_sessions_between(
3596            NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(),
3597            NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(),
3598        );
3599        assert_eq!(s.len(), 2);
3600        assert_eq!(s[0].0, "pre_open");
3601        assert_eq!(s[1].0, "after_close");
3602
3603        let pre_open_local = s[0].1.with_timezone(&chrono_tz::America::New_York);
3604        let pre_close_local = s[0].2.with_timezone(&chrono_tz::America::New_York);
3605        assert_eq!((pre_open_local.hour(), pre_open_local.minute()), (4, 0));
3606        assert_eq!((pre_close_local.hour(), pre_close_local.minute()), (9, 30));
3607
3608        let after_open_local = s[1].1.with_timezone(&chrono_tz::America::New_York);
3609        let after_close_local = s[1].2.with_timezone(&chrono_tz::America::New_York);
3610        assert_eq!(
3611            (after_open_local.hour(), after_open_local.minute()),
3612            (16, 0)
3613        );
3614        assert_eq!(
3615            (after_close_local.hour(), after_close_local.minute()),
3616            (20, 0)
3617        );
3618    }
3619
3620    #[test]
3621    fn nyse_after_close_starts_at_early_close() {
3622        let cal = calendar_for_exchange("XNYS").unwrap();
3623        let s = cal.extended_sessions_between(
3624            NaiveDate::from_ymd_opt(2024, 7, 3).unwrap(),
3625            NaiveDate::from_ymd_opt(2024, 7, 3).unwrap(),
3626        );
3627        let after_open_local = s[1].1.with_timezone(&chrono_tz::America::New_York);
3628        let after_close_local = s[1].2.with_timezone(&chrono_tz::America::New_York);
3629        assert_eq!(
3630            (after_open_local.hour(), after_open_local.minute()),
3631            (13, 0)
3632        );
3633        assert_eq!(
3634            (after_close_local.hour(), after_close_local.minute()),
3635            (20, 0)
3636        );
3637    }
3638
3639    #[test]
3640    fn nyse_holidays_between_q3_2024() {
3641        let cal = calendar_for_exchange("XNYS").unwrap();
3642        let h = cal.holidays_between(
3643            NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
3644            NaiveDate::from_ymd_opt(2024, 9, 30).unwrap(),
3645        );
3646        // Jul 4 (Independence Day) and Sep 2 (Labor Day).
3647        assert!(h.contains(&NaiveDate::from_ymd_opt(2024, 7, 4).unwrap()));
3648        assert!(h.contains(&NaiveDate::from_ymd_opt(2024, 9, 2).unwrap()));
3649        assert_eq!(h.len(), 2);
3650    }
3651}