diff --git a/docs/src/API.md b/docs/src/API.md index c1ad55a..8107c78 100644 --- a/docs/src/API.md +++ b/docs/src/API.md @@ -127,6 +127,8 @@ Useful attributes: ```python nyse.name # "XNYS" +nyse.source_kind # "modeled" +nyse.source_calendar # None nyse.market_type # "Equities" nyse.weekmask # [True, True, True, True, True, False, False] nyse.timezone # "America/New_York" @@ -146,6 +148,22 @@ energy.regular_sessions # [(17, 0, -1, 16, 0, 0)] ``` +`source_kind` and `source_calendar` are read-only attributes. `source_kind` +is `"modeled"` for explicit family/product mappings, `"region_fallback"` +for a representative country calendar, `"weekmask_fallback"` for a +weekday-only calendar without holidays or trading hours, and `"range"` +for `from_range()`. Modeled families may be shared by multiple venues; +this classification does not guarantee venue-specific accuracy. +`source_calendar` is the original representative calendar name for a region +fallback and `None` otherwise. Product and asset lookups preserve exchange +fallback provenance. + +```python +shfe = Calendar.from_exchange("XSGE") +assert shfe.source_kind == "region_fallback" +assert shfe.source_calendar == "XSHG" +``` + Date methods: ```python @@ -332,7 +350,7 @@ krx.timezone # "Asia/Seoul" krx.holidays(2024) # Korean holidays including lunar Seollal and Chuseok tase = Calendar.from_exchange("XTAE") -tase.weekmask # Sunday-Thursday trading week +tase.weekmask # Sunday-Thursday base weekmask; date-based methods use Monday-Friday from 2026-01-05 ``` ### Bucket dates into periods diff --git a/docs/src/CALENDARS.md b/docs/src/CALENDARS.md index e522530..8bb05f8 100644 --- a/docs/src/CALENDARS.md +++ b/docs/src/CALENDARS.md @@ -61,8 +61,10 @@ Calendar objects expose: | Attribute / method | Meaning | | ---------------------------------------------- | ----------------------------------------------------------------------------------- | | `name` | Calendar code used by the resolver | +| `source_kind` | `modeled`, `region_fallback`, `weekmask_fallback`, or `range` | +| `source_calendar` | Representative calendar name for a region fallback, otherwise `None` | | `market_type` | `finance-enums` `MarketType` label, e.g. `Equities`, `Options`, `Futures` | -| `weekmask` | Seven booleans, indexed Monday through Sunday | +| `weekmask` | Base weekmask, indexed Monday through Sunday | | `timezone` | IANA timezone for trading hours, or `""` if no hours are configured | | `regular_sessions` | One or more local regular session templates as open/close hour/minute/offset tuples | | `extended_hours` | Named local extended-hours templates where available | @@ -86,10 +88,33 @@ plain weekmask-only calendar with the appropriate `market_type`. Codes that are entirely unknown, and region codes outside the supported set, raise `ValueError` in Python. +`source_kind` identifies how the schedule was selected. `modeled` means an +explicit family or product mapping, which may be shared by multiple venues +and does not guarantee venue-specific accuracy. `region_fallback` uses a +representative country calendar whose original name is in `source_calendar`. +`weekmask_fallback` has no modeled holidays or trading hours. Calendars from +`from_range()` have `source_kind == "range"`. Product and asset lookups +preserve this provenance when they fall back to an exchange calendar. + +```python +shfe = Calendar.from_exchange("XSGE") +assert shfe.name == "XSGE" +assert shfe.source_kind == "region_fallback" +assert shfe.source_calendar == "XSHG" +``` + +In Rust, `Calendar.source` is a `CalendarSource` enum with `Modeled`, +`RegionFallback { source_calendar }`, `WeekmaskFallback`, and `Custom` +variants. Direct construction with `Calendar::new` or `Calendar::with_type` +sets `Custom`. + The `weekmask` is always seven booleans indexed Monday through Sunday. Most venues use the standard Monday-Friday week. Saudi Arabia (`XSAU`) -and Tel Aviv (`XTAE`) trade Sunday through Thursday, `FOREX` trades -Sunday through Friday, and `CRYPTO` trades every day. +trades Sunday through Thursday. Tel Aviv (`XTAE`) uses Sunday-Thursday +before 2026-01-05 and Monday-Friday from that date, with Friday regular +sessions closing at 13:34 local time before the closing auction. Date-based +calendar methods apply this schedule change; `weekmask` exposes the base +schedule. `FOREX` trades Sunday through Friday, and `CRYPTO` trades every day. ______________________________________________________________________ @@ -119,7 +144,8 @@ contracts whose hours differ materially from the broad MIC family. Every venue below has a dedicated national/exchange holiday rule set. Tokyo, Hong Kong, and Shanghai use split regular sessions for their lunch breaks; the rest use a single local continuous session. Saudi -Arabia and Tel Aviv use a Sunday-through-Thursday weekmask. +Arabia uses a Sunday-through-Thursday weekmask. Tel Aviv uses that weekmask +before its 2026 transition to Monday-Friday trading. | Region | Exchanges (MIC) | Holiday-system highlights | | -------------- | ------------------------------------ | -------------------------------------------------------------------------------------- | @@ -164,7 +190,7 @@ Arabia and Tel Aviv use a Sunday-through-Thursday weekmask. | New Zealand | `XNZE` | New Zealand holidays including tabulated Matariki | | Saudi Arabia | `XSAU` | Sunday-Thursday week; Eid and national days tabulated | | Turkey | `XIST` | Turkish national holidays plus tabulated Eid | -| Israel | `XTAE` | Sunday-Thursday week; Hebrew-calendar Jewish holidays | +| Israel | `XTAE` | Sunday-Thursday before 2026-01-05, then Monday-Friday; Jewish holidays | | UAE | `XDFM`, `XADS` | Emirati national holidays plus tabulated Islamic dates | | South Africa | `XJSE` | South African public holidays with Sunday-to-Monday rolls | @@ -429,17 +455,22 @@ Important boundaries: bridge/make-up tables and similar civic-arrangement tables extend to roughly 2030, and the Eid al-Fitr / Eid al-Adha tables for Saudi Arabia and Turkey currently run through 2026. Dates beyond a table's horizon - are not modeled until the table is extended. + are not modeled until the table is extended. Indian holiday tables run + through 2026; Tel Aviv's holiday tables run through 2027. - Trading-hours templates are date-effective only where explicitly implemented, - currently including Tokyo's 2024 close-time extension. Other historical - schedule changes may still use current-rule approximations. -- Weekmasks are static per calendar. Saudi Arabia (`XSAU`) and Tel Aviv - (`XTAE`) trade Sunday-Thursday; other venues use a Monday-Friday week. + currently including Tokyo's 2024 close-time extension and Tel Aviv's 2026 + trading-week change. Other historical schedule changes may still use + current-rule approximations. +- Saudi Arabia (`XSAU`) trades Sunday-Thursday. Tel Aviv (`XTAE`) uses a + date-effective weekmask; most other venues use a Monday-Friday week. - Some special closures, ad-hoc national mourning days, weather events, or emergency interruptions may not be modeled beyond the one-off dates already tabulated. - Early closes are NYSE-shaped: a single early close time applied to the - final regular session. Non-US half days are not generally modeled. + final regular session. Tel Aviv's Friday early closes are modeled from + 2026; other non-US half days are not generally modeled. +- Indian calendars model regular sessions. Special Muhurat sessions on + regular trading holidays are excluded. - `holidays(start, end)` returns holidays, not every invalid date. - `business_days()` is inclusive of both endpoints. - Extended-hours coverage is currently populated where the calendar has a diff --git a/finance_dates/tests/test_all.py b/finance_dates/tests/test_all.py index 5cf8106..44ea583 100644 --- a/finance_dates/tests/test_all.py +++ b/finance_dates/tests/test_all.py @@ -215,6 +215,60 @@ def test_unknown_exchange_raises() -> None: Calendar.from_exchange("ZZZZ") +@pytest.mark.parametrize("code", ["XNYS", "xnys", "XSHG", "PINX", "CBOT_GRAINS", "FOREX", "CRYPTO"]) +def test_modeled_calendar_source(code: str) -> None: + cal = Calendar.from_exchange(code) + assert cal.source_kind == "modeled" + assert cal.source_calendar is None + + +@pytest.mark.parametrize("code", ["XSGE", "XDCE", "XZCE", "xsge"]) +def test_chinese_futures_calendar_reports_region_fallback(code: str) -> None: + cal = Calendar.from_exchange(code) + region = Calendar.from_region("CN") + day = date(2026, 9, 15) + + assert cal.name == code.upper() + assert cal.source_kind == "region_fallback" + assert cal.source_calendar == "XSHG" + assert cal.market_type == "Equities" + assert cal.sessions(day, day) == region.sessions(day, day) + assert cal.holidays(2026) == region.holidays(2026) + assert region.source_kind == "modeled" + assert region.source_calendar is None + + +def test_weekmask_calendar_reports_fallback() -> None: + cal = Calendar.from_exchange("24EX") + assert cal.source_kind == "weekmask_fallback" + assert cal.source_calendar is None + assert cal.weekmask == [True, True, True, True, True, False, False] + assert cal.holidays(2026) == [] + assert cal.regular_sessions == [] + + +def test_product_and_asset_calendar_sources() -> None: + for cal in [ + Calendar.from_product("XSGE", "Gold"), + Calendar.from_asset("XSGE", "Commodity", subclass="Gold"), + ]: + assert cal.source_kind == "region_fallback" + assert cal.source_calendar == "XSHG" + + for cal in [ + Calendar.from_product("XNYM", "NaturalGas"), + Calendar.from_asset("XNYM", "Commodity", subclass="NaturalGas"), + ]: + assert cal.source_kind == "modeled" + assert cal.source_calendar is None + + +def test_range_calendar_source() -> None: + cal = Calendar.from_range(date(2026, 9, 14), date(2026, 9, 15)) + assert cal.source_kind == "range" + assert cal.source_calendar is None + + def test_holidays_year_returns_dates() -> None: cal = Calendar.from_exchange("XNYS") hs = cal.holidays(2024) @@ -538,6 +592,47 @@ def test_tase_uses_sun_thu_weekmask() -> None: assert wm[6] is True +@pytest.mark.parametrize("code", ["XNSE", "XBOM"]) +@pytest.mark.parametrize("day", [date(2024, 1, 22), date(2024, 11, 20), date(2026, 1, 15)]) +def test_indian_exchange_special_closures(code: str, day: date) -> None: + cal = Calendar.from_exchange(code) + assert cal.is_holiday(day) + assert cal.sessions(day, day) == [] + + +def test_nse_eid_2020_dates() -> None: + cal = Calendar.from_exchange("XNSE") + assert cal.is_holiday(date(2020, 5, 25)) + assert cal.is_business_day(date(2020, 7, 31)) + + +@pytest.mark.parametrize("day", [date(2016, 12, 30), date(2017, 12, 29), date(2022, 12, 30), date(2023, 12, 29)]) +def test_swiss_new_years_eve_does_not_roll(day: date) -> None: + cal = Calendar.from_exchange("XSWX") + assert cal.is_business_day(day) + assert cal.is_holiday(date(day.year, 12, 31)) + + +def test_tase_trading_week_changes_in_2026() -> None: + cal = Calendar.from_exchange("XTAE") + assert cal.is_business_day(date(2025, 12, 28)) + assert not cal.is_business_day(date(2025, 12, 26)) + assert not cal.is_business_day(date(2026, 1, 11)) + assert cal.is_business_day(date(2026, 1, 9)) + assert cal.next_business_day(date(2026, 1, 8)) == date(2026, 1, 9) + assert cal.sessions(date(2026, 1, 11), date(2026, 1, 11)) == [] + assert cal.sessions(date(2026, 1, 9), date(2026, 1, 9)) == [ + (datetime(2026, 1, 9, 7, 59, tzinfo=UTC), datetime(2026, 1, 9, 11, 34, tzinfo=UTC)), + ] + + +@pytest.mark.parametrize("day", [date(2026, 3, 3), date(2026, 4, 22), date(2026, 9, 18), date(2027, 3, 23), date(2027, 10, 8)]) +def test_tase_holidays_after_trading_week_change(day: date) -> None: + cal = Calendar.from_exchange("XTAE") + assert cal.is_holiday(day) + assert cal.sessions(day, day) == [] + + def test_korean_seollal_2024_multi_day_holiday() -> None: cal = Calendar.from_exchange("XKRX") assert cal.is_holiday(date(2024, 2, 9)) is True diff --git a/finance_dates/tests/test_reference_calendars.py b/finance_dates/tests/test_reference_calendars.py index 83798ef..cec90fa 100644 --- a/finance_dates/tests/test_reference_calendars.py +++ b/finance_dates/tests/test_reference_calendars.py @@ -86,9 +86,9 @@ "XSHG": ("SSE", "XSHG"), } -# Dates where finance-dates intentionally differs from ONE reference because that -# reference is wrong (verified against the official exchange calendar and the -# other reference). Only consulted when a single reference is available. +# Dates where finance-dates intentionally differs from ONE reference because of +# a verified reference error or an unmodeled special session. Only consulted +# when a single reference is available. KNOWN_DIVERGENCES: dict[str, dict[dt.date, str]] = { # LSE traded on 2022-05-30: the spring bank holiday was moved to Jun 2 for # the Platinum Jubilee (pandas-market-calendars marks it closed). @@ -99,6 +99,15 @@ dt.date(2021, 4, 6): "Ching Ming substitute (Sunday -> after Easter Monday)", dt.date(2026, 4, 7): "Ching Ming substitute (Sunday -> after Easter Monday)", }, + # NSE lists these as regular trading holidays with a separate Muhurat session. + # https://www.nseindia.com/resources/exchange-communication-holidays + # https://nsearchives.nseindia.com/content/circulars/CMPT50078.pdf + "XNSE": { + dt.date(2021, 11, 4): "special Muhurat session; regular session closed", + dt.date(2022, 10, 24): "special Muhurat session; regular session closed", + dt.date(2024, 11, 1): "special Muhurat session; regular session closed", + dt.date(2025, 10, 21): "special Muhurat session; regular session closed", + }, } @@ -176,3 +185,16 @@ def test_known_reference_divergences_are_still_present() -> None: def test_nyse_carter_day_of_mourning_regression() -> None: """The closure that started this whole effort stays closed.""" assert Calendar.from_exchange("XNYS").is_holiday(dt.date(2025, 1, 9)) + + +@pytest.mark.parametrize("day", sorted(KNOWN_DIVERGENCES["XNSE"])) +def test_nse_muhurat_sessions_are_outside_regular_calendar(day: dt.date) -> None: + sessions = _pmc_sessions("NSE") + if sessions is None: + pytest.skip("NSE reference calendar unavailable") + special_dates = {dt.date.fromisoformat(str(day)[:10]) for _, days in mcal.get_calendar("NSE").special_opens_adhoc for day in days} + assert day in sessions + assert day in special_dates + cal = Calendar.from_exchange("XNSE") + assert cal.is_holiday(day) + assert cal.sessions(day, day) == [] diff --git a/pyproject.toml b/pyproject.toml index 556806a..66eaed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,7 @@ develop = [ # Their holiday data ships in the package, so the check is deterministic and # needs no network access. Not vendored into our source. reference = [ - "pandas-market-calendars", + "pandas-market-calendars>=5.5", "exchange-calendars", ] diff --git a/rust/python/lib.rs b/rust/python/lib.rs index dc282a8..302833c 100644 --- a/rust/python/lib.rs +++ b/rust/python/lib.rs @@ -8,8 +8,8 @@ use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; use ::finance_dates::{ business_day_range as core_business_day_range, calendar_for_asset, calendar_for_exchange, - calendar_for_product, calendar_for_region, date_range as core_date_range, COUNTRY_CODES, - COUNTRY_CODES3, EXCHANGE_CODES, STANDARD_WEEKMASK, + calendar_for_product, calendar_for_region, date_range as core_date_range, CalendarSource, + COUNTRY_CODES, COUNTRY_CODES3, EXCHANGE_CODES, STANDARD_WEEKMASK, }; fn pydate_to_naive(d: &Bound<'_, PyDate>) -> PyResult { @@ -231,6 +231,29 @@ impl PyCalendar { } } + /// Schedule source: modeled, region_fallback, weekmask_fallback, custom, or range. + #[getter] + fn source_kind(&self) -> &'static str { + match self.inner.as_ref().map(|inner| &inner.source) { + Some(CalendarSource::Modeled) => "modeled", + Some(CalendarSource::RegionFallback { .. }) => "region_fallback", + Some(CalendarSource::WeekmaskFallback) => "weekmask_fallback", + Some(CalendarSource::Custom) => "custom", + None => "range", + } + } + + /// Representative calendar name for a region fallback, otherwise None. + #[getter] + fn source_calendar(&self) -> Option { + match self.inner.as_ref().map(|inner| &inner.source) { + Some(CalendarSource::RegionFallback { source_calendar }) => { + Some(source_calendar.clone()) + } + _ => None, + } + } + #[getter] fn market_type(&self) -> PyResult { match &self.inner { diff --git a/rust/src/calendar.rs b/rust/src/calendar.rs index 6b0005b..327ccaf 100644 --- a/rust/src/calendar.rs +++ b/rust/src/calendar.rs @@ -94,9 +94,24 @@ struct ResolvedSchedule<'a> { trading_hours: Option<&'a TradingHours>, } +/// How a calendar's schedule was selected. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CalendarSource { + /// An explicit calendar family or product mapping, which may be shared by venues. + Modeled, + /// A representative country calendar used for a MIC without a modeled family. + RegionFallback { source_calendar: String }, + /// A weekday-only calendar without modeled holidays or trading hours. + WeekmaskFallback, + /// A calendar built directly with `Calendar::new` or `Calendar::with_type`. + Custom, +} + /// A holiday calendar with optional trading hours and a market classification. pub struct Calendar { pub name: String, + /// Schedule provenance; modeled families are not a guarantee of venue accuracy. + pub source: CalendarSource, /// One of the `MARKET_TYPES` entries, aligned with `finance-enums` `MarketType` variants. pub market_type: &'static str, pub weekmask: [bool; 7], @@ -196,6 +211,7 @@ impl Calendar { ) -> Self { Self { name: name.into(), + source: CalendarSource::Custom, market_type, weekmask, rules, @@ -643,7 +659,7 @@ fn fixed_between( } /// Fixed date rolled back to the preceding Friday when it lands on a weekend -/// (year-end closure convention at SIX, B3, BVC). +/// (year-end closure convention at B3, BVC). fn fixed_prev_fri(month: u32, day: u32) -> HolidayRule { HolidayRule::Fixed { month, @@ -1618,7 +1634,10 @@ fn nse_rules() -> Vec { } /// India: Islamic/festival closures and bridge days, curated from the -/// official exchange calendar (2015-2030). +/// official exchange calendar (2015-2026). +/// 2020 dates: https://nsearchives.nseindia.com/content/circulars/FAOP42878.pdf +/// Special closures: CMTR60338 (2024-01-22), FAOP64959 (2024-11-20), and +/// https://www.nseindia.com/resources/exchange-communication-holidays (2026-01-15). static NSE_ADDS: &[(i32, u32, u32)] = &[ (2015, 2, 17), (2015, 3, 6), @@ -1684,7 +1703,7 @@ static NSE_ADDS: &[(i32, u32, u32)] = &[ (2020, 4, 10), (2020, 4, 14), (2020, 5, 1), - (2020, 7, 31), + (2020, 5, 25), (2020, 11, 16), (2020, 11, 30), (2021, 3, 11), @@ -1722,6 +1741,7 @@ static NSE_ADDS: &[(i32, u32, u32)] = &[ (2023, 10, 24), (2023, 11, 14), (2023, 11, 27), + (2024, 1, 22), (2024, 3, 8), (2024, 3, 25), (2024, 3, 29), @@ -1733,6 +1753,7 @@ static NSE_ADDS: &[(i32, u32, u32)] = &[ (2024, 7, 17), (2024, 11, 1), (2024, 11, 15), + (2024, 11, 20), (2025, 2, 26), (2025, 3, 14), (2025, 3, 31), @@ -1744,6 +1765,7 @@ static NSE_ADDS: &[(i32, u32, u32)] = &[ (2025, 10, 21), (2025, 10, 22), (2025, 11, 5), + (2026, 1, 15), (2026, 3, 3), (2026, 3, 26), (2026, 3, 31), @@ -1954,7 +1976,9 @@ fn xswx_rules() -> Vec { fixed_no_roll(12, 24, None), fixed_no_roll(12, 25, None), fixed_no_roll(12, 26, None), - fixed_prev_fri(12, 31), // New Year's Eve (preceding Friday if weekend) + // SIX Trading Guide: December 31 is closed without a weekend substitute. + // https://www.six-group.com/dam/download/the-swiss-stock-exchange/trading/trading-provisions/regulation/trading-guides/trading-guide.pdf + fixed_no_roll(12, 31, None), ] } @@ -3741,7 +3765,7 @@ fn xist_hours() -> TradingHours { ) } -/// Sun-Thu weekmask used by TASE. +/// Sun-Thu weekmask used by TASE before 2026-01-05. const TASE_WEEKMASK: [bool; 7] = [true, true, true, true, false, false, true]; fn xtae_rules() -> Vec { @@ -3827,8 +3851,9 @@ fn xtae_rules() -> Vec { ] } -/// Tel Aviv: Islamic/festival closures and bridge days, curated from the -/// official exchange calendar (2015-2030). +/// Tel Aviv: Jewish holiday closures and bridge days (2019-2027). +/// https://www.tase.co.il/en/content/knowledge_center/trading_vacation_schedule/ +/// 2026-2027 dates corroborated by pandas-market-calendars and exchange-calendars. static XTAE_ADDS: &[(i32, u32, u32)] = &[ (2019, 3, 21), (2019, 4, 9), @@ -3906,8 +3931,51 @@ static XTAE_ADDS: &[(i32, u32, u32)] = &[ (2025, 10, 6), (2025, 10, 13), (2025, 10, 14), + (2026, 4, 2), + (2026, 4, 7), + (2026, 4, 8), + (2026, 4, 21), + (2026, 5, 21), + (2026, 7, 23), + (2026, 9, 11), + (2026, 9, 18), + (2026, 9, 25), + (2026, 10, 2), + (2027, 3, 23), + (2027, 4, 21), + (2027, 4, 22), + (2027, 4, 27), + (2027, 4, 28), + (2027, 5, 11), + (2027, 5, 12), + (2027, 6, 10), + (2027, 6, 11), + (2027, 8, 12), + (2027, 10, 1), + (2027, 10, 8), + (2027, 10, 11), + (2027, 10, 15), + (2027, 10, 22), ]; -static XTAE_SKIP: &[(i32, u32, u32)] = &[(2026, 3, 3), (2026, 4, 1), (2026, 4, 22), (2026, 9, 21)]; + +fn xtae_friday_early_closes() -> Vec { + // Continuous trading ends before the closing auction on Fridays from 2026. + // https://www.new.isa.gov.il/images/Fittings/isa/asset_library_pic/al_lobby/al_lobby-65d5b849b3af3/Modification_TradingDays.pdf + (1..=12) + .flat_map(|month| { + (1..=5).map(move |n| EarlyCloseRule { + rule: HolidayRule::NthWeekday { + month, + weekday: Weekday::Fri, + n, + since_year: Some(2026), + until_year: None, + }, + close_time: NaiveTime::from_hms_opt(13, 34, 0).unwrap(), + }) + }) + .collect() +} fn xtae_hours() -> TradingHours { TradingHours::new( @@ -4528,7 +4596,7 @@ fn family_for_mic(mic: &str) -> Option { fn build_family(name: &str, fam: Family) -> Calendar { use Family::*; - match fam { + let mut calendar = match fam { UsEquity => Calendar::with_type( name, market_type("Equities"), @@ -4886,7 +4954,15 @@ fn build_family(name: &str, fam: Family) -> Calendar { xtae_rules(), Some(xtae_hours()), ) - .with_exceptions(XTAE_SKIP), + // TASE's Monday-Friday trading week starts 2026-01-05. + // https://www.tase.co.il/en/content/about/tradingdays_change + .with_schedules(vec![CalendarSchedule::new( + NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(), + STANDARD_WEEKMASK, + xtae_rules(), + Some(xtae_hours()), + )]) + .with_early_closes(xtae_friday_early_closes()), Xdfm => Calendar::with_type( name, market_type("Equities"), @@ -4941,11 +5017,16 @@ fn build_family(name: &str, fam: Family) -> Calendar { xbog_rules(), Some(xbog_hours()), ), - } + }; + calendar.source = CalendarSource::Modeled; + calendar } /// Build a calendar from its MIC code (or a generic family name like -/// `FOREX`, `CRYPTO`, `SIFMA_US`, `ICE_US`, `CFE`). Returns `None` if unknown. +/// `FOREX`, `CRYPTO`, `SIFMA_US`, `ICE_US`, `CFE`). Returns `None` if the code +/// has neither a modeled family nor a finance-enums exchange record. +/// Registered MICs without a modeled family use a representative country +/// calendar or a weekday-only calendar. Inspect `Calendar::source` for provenance. pub fn calendar_for_exchange(code: &str) -> Option { let upper = code.to_ascii_uppercase(); if let Some(fam) = family_for_mic(&upper) { @@ -4954,17 +5035,22 @@ pub fn calendar_for_exchange(code: &str) -> Option { let record = finance_enums::data::exchange_record(&upper)?; if let Some(mut calendar) = calendar_for_region(record.iso_country_code) { + calendar.source = CalendarSource::RegionFallback { + source_calendar: calendar.name.clone(), + }; calendar.name = upper; return Some(calendar); } - Some(Calendar::with_type( + let mut calendar = Calendar::with_type( upper, market_type_for_exchange_record(record), STANDARD_WEEKMASK, Vec::new(), None, - )) + ); + calendar.source = CalendarSource::WeekmaskFallback; + Some(calendar) } fn market_type_for_exchange_record(record: &finance_enums::data::ExchangeRecord) -> &'static str { @@ -5511,6 +5597,84 @@ mod tests { assert!(cal.is_open(inst)); } + #[test] + fn modeled_calendar_source() { + for code in [ + "XNYS", + "xnys", + "XSHG", + "PINX", + "CBOT_GRAINS", + "FOREX", + "CRYPTO", + ] { + assert_eq!( + calendar_for_exchange(code).unwrap().source, + CalendarSource::Modeled + ); + } + assert!(calendar_for_exchange("ZZZZ").is_none()); + } + + #[test] + fn chinese_futures_calendar_reports_region_fallback() { + let region = calendar_for_region("CN").unwrap(); + let day = NaiveDate::from_ymd_opt(2026, 9, 15).unwrap(); + assert_eq!(region.source, CalendarSource::Modeled); + for code in ["XSGE", "XDCE", "XZCE", "xsge"] { + let cal = calendar_for_exchange(code).unwrap(); + assert_eq!(cal.name, code.to_ascii_uppercase()); + assert_eq!( + cal.source, + CalendarSource::RegionFallback { + source_calendar: "XSHG".to_string(), + } + ); + assert_eq!(cal.market_type, market_type("Equities")); + assert_eq!( + cal.sessions_between(day, day), + region.sessions_between(day, day) + ); + assert_eq!(cal.holidays(2026), region.holidays(2026)); + } + } + + #[test] + fn weekmask_calendar_reports_fallback() { + let cal = calendar_for_exchange("24EX").unwrap(); + assert_eq!(cal.source, CalendarSource::WeekmaskFallback); + assert_eq!(cal.weekmask, STANDARD_WEEKMASK); + assert!(cal.rules.is_empty()); + assert!(cal.trading_hours.is_none()); + } + + #[test] + fn product_and_asset_calendar_sources() { + for cal in [ + calendar_for_product("XSGE", "Gold").unwrap(), + calendar_for_asset("XSGE", "Commodity", Some("Gold")).unwrap(), + ] { + assert_eq!( + cal.source, + CalendarSource::RegionFallback { + source_calendar: "XSHG".to_string(), + } + ); + } + for cal in [ + calendar_for_product("XNYM", "NaturalGas").unwrap(), + calendar_for_asset("XNYM", "Commodity", Some("NaturalGas")).unwrap(), + ] { + assert_eq!(cal.source, CalendarSource::Modeled); + } + } + + #[test] + fn custom_calendar_source() { + let cal = Calendar::new("custom", STANDARD_WEEKMASK, Vec::new(), None); + assert_eq!(cal.source, CalendarSource::Custom); + } + #[test] fn all_exchange_codes_resolve() { let mut missing = Vec::new(); @@ -5640,6 +5804,81 @@ mod tests { assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2024, 2, 12).unwrap())); } + #[test] + fn indian_exchange_special_closures() { + for code in ["XNSE", "XBOM"] { + let cal = calendar_for_exchange(code).unwrap(); + for (y, m, d) in [(2024, 1, 22), (2024, 11, 20), (2026, 1, 15)] { + let day = NaiveDate::from_ymd_opt(y, m, d).unwrap(); + assert!(cal.is_holiday(day)); + assert!(cal.sessions_between(day, day).is_empty()); + } + } + } + + #[test] + fn nse_eid_2020_dates() { + let cal = calendar_for_exchange("XNSE").unwrap(); + assert!(cal.is_holiday(NaiveDate::from_ymd_opt(2020, 5, 25).unwrap())); + assert!(cal.is_business_day(NaiveDate::from_ymd_opt(2020, 7, 31).unwrap())); + } + + #[test] + fn swiss_new_years_eve_does_not_roll() { + let cal = calendar_for_exchange("XSWX").unwrap(); + for (y, m, d) in [ + (2016, 12, 30), + (2017, 12, 29), + (2022, 12, 30), + (2023, 12, 29), + ] { + assert!(cal.is_business_day(NaiveDate::from_ymd_opt(y, m, d).unwrap())); + assert!(cal.is_holiday(NaiveDate::from_ymd_opt(y, 12, 31).unwrap())); + } + } + + #[test] + fn xtae_trading_week_changes_in_2026() { + let cal = calendar_for_exchange("XTAE").unwrap(); + assert!(cal.is_business_day(NaiveDate::from_ymd_opt(2025, 12, 28).unwrap())); + assert!(!cal.is_business_day(NaiveDate::from_ymd_opt(2025, 12, 26).unwrap())); + assert!(!cal.is_business_day(NaiveDate::from_ymd_opt(2026, 1, 11).unwrap())); + let friday = NaiveDate::from_ymd_opt(2026, 1, 9).unwrap(); + assert!(cal.is_business_day(friday)); + assert_eq!( + cal.next_business_day(NaiveDate::from_ymd_opt(2026, 1, 8).unwrap()), + friday + ); + assert_eq!( + cal.early_close_for(friday), + NaiveTime::from_hms_opt(13, 34, 0) + ); + assert_eq!( + cal.early_close_for(NaiveDate::from_ymd_opt(2026, 7, 31).unwrap()), + NaiveTime::from_hms_opt(13, 34, 0) + ); + assert_eq!( + cal.sessions_between(friday, friday)[0].1, + Utc.with_ymd_and_hms(2026, 1, 9, 11, 34, 0).unwrap() + ); + } + + #[test] + fn xtae_holidays_after_trading_week_change() { + let cal = calendar_for_exchange("XTAE").unwrap(); + for (y, m, d) in [ + (2026, 3, 3), + (2026, 4, 22), + (2026, 9, 18), + (2027, 3, 23), + (2027, 10, 8), + ] { + let day = NaiveDate::from_ymd_opt(y, m, d).unwrap(); + assert!(cal.is_holiday(day)); + assert!(cal.sessions_between(day, day).is_empty()); + } + } + #[test] fn xtae_uses_sun_thu_weekmask() { let cal = calendar_for_exchange("XTAE").unwrap(); diff --git a/rust/src/holiday.rs b/rust/src/holiday.rs index 9291720..da9318f 100644 --- a/rust/src/holiday.rs +++ b/rust/src/holiday.rs @@ -16,7 +16,7 @@ pub enum WeekendRoll { /// Sun → Mon only; Saturday holidays are not substituted (South Africa). SundayToMonday, /// Sat → Fri, Sun → preceding Fri; a weekend holiday moves to the last - /// weekday before it (SIX New Year's Eve). + /// weekday before it (B3 New Year's Eve). PrecedingFriday, } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 1ac69f8..5b4c84e 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -8,9 +8,9 @@ pub mod trading_hours; pub use calendar::{ calendar_for_asset, calendar_for_exchange, calendar_for_product, calendar_for_region, Calendar, - CalendarSchedule, AGRICULTURE_TYPES, COMMODITY_TYPES, COUNTRY_CODES, COUNTRY_CODES3, - CRYPTO_WEEKMASK, ENERGY_TYPES, EXCHANGE_CODES, FX_WEEKMASK, MARKET_TYPES, METALS_TYPES, - UNDERLYING_ASSET_CLASSES, + CalendarSchedule, CalendarSource, AGRICULTURE_TYPES, COMMODITY_TYPES, COUNTRY_CODES, + COUNTRY_CODES3, CRYPTO_WEEKMASK, ENERGY_TYPES, EXCHANGE_CODES, FX_WEEKMASK, MARKET_TYPES, + METALS_TYPES, UNDERLYING_ASSET_CLASSES, }; pub use holiday::{HolidayRule, Weekday}; pub use range::{business_day_range, date_range, STANDARD_WEEKMASK};