Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/src/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 42 additions & 11 deletions docs/src/CALENDARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.

______________________________________________________________________

Expand Down Expand Up @@ -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 |
| -------------- | ------------------------------------ | -------------------------------------------------------------------------------------- |
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions finance_dates/tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions finance_dates/tests/test_reference_calendars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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",
},
}


Expand Down Expand Up @@ -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) == []
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
27 changes: 25 additions & 2 deletions rust/python/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NaiveDate> {
Expand Down Expand Up @@ -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<String> {
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<String> {
match &self.inner {
Expand Down
Loading
Loading