Skip to main content

better-calendar

One place for date logic: business-day arithmetic, calendar-aware offsets, date normalisation, recurrence generation and calendar composition.

A calendar is a sorted int64 array of good days over a bounded horizon. Membership, offsets, counting and set algebra all reduce to searchsorted and numpy set operations on that array — so everything is O(log n) per element and vectorised.

import better_calendar as bcal

bcal.adjust("2026-05-31", "MF")  # '2026-05-29'  Sunday; forward would leave May
bcal.offset("2026-07-31", 5)  # '2026-08-07'
bcal.count("2026-07-27", "2026-08-01")  # 5            half-open [start, end)

numpy is the only required dependency. Importing the package does not import pandas, and answering a query never imports a holiday provider.

477 calendars ship in the wheel: 59 exchanges, 251 countries, 91 QuantLib settlement and rate calendars, and 76 from workalendar.

Install

pip install better-calendar                  # numpy only
pip install 'better-calendar[pandas]'        # DatetimeIndex / Timestamp output
pip install 'better-calendar[config]'        # read the organisation config file
pip install 'better-calendar[all]'           # every provider, for snapshot generation

Python 3.9+. The provider extras are only needed to regenerate the holiday snapshot; using the shipped one needs nothing but numpy.

Documentation

  • docs/reference.md — every public function, the dependency story, and the design decisions behind the parts that are easy to get wrong.
  • docs/calendar-data.md — where the holiday data comes from, why it is frozen, how an upstream correction reaches you, and how to add your own closures.
  • notebooks/ — six runnable notebooks covering the whole public API, committed with their outputs and re-executed in CI.
# Notebook
01 Getting started: types, roll conventions, intervals, bounds
02 Calendars, registry, provenance, algebra and its vocabulary trap
03 BDay, pandas interop, tenors, the month-end rules, settlement dates
04 The schedule engine, named shortcuts, coupon schedules and stubs
05 Timezones, session_of, session_bounds, grid, at_times
06 Snapshots, upstream drift, the CLI, organisation calendars

What it gives you

Type transparency

Whatever you pass in comes back out. date in, date out; Timestamp in, Timestamp out; sequence in, DatetimeIndex out (a datetime64[D] array when pandas is absent).

from datetime import date
import better_calendar as bcal

bcal.offset(date(2026, 7, 31), 1)  # datetime.date(2026, 8, 3)
bcal.offset("20260731", 1)  # '20260803'
bcal.offset(20260731, 1)  # 20260803

Accepted inputs: date, datetime, pandas.Timestamp, numpy.datetime64, ISO-8601 strings, the compact "20260731" form, and yyyymmdd ints. DD/MM/YYYY is rejected on purpose — there is no way to tell it from MM/DD/YYYY, and a wrong guess is silent.

Roll conventions

from better_calendar import Roll

bcal.adjust("2026-08-01")  # '2026-08-03'  following
bcal.adjust("2026-05-31", Roll.MODIFIED_FOLLOWING)  # '2026-05-29'
bcal.adjust("2026-08-01", "P")  # '2026-07-31'  preceding

NONE, FOLLOWING, PRECEDING, MODIFIED_FOLLOWING, MODIFIED_PRECEDING, NEAREST (ties go forward) and RAISE. Short ISDA aliases ("F", "MF", …) are accepted case-insensitively everywhere.

Calendar algebra

Operations are named after business days, never after holidays — because "union of two calendars" means opposite things depending on which one the speaker has in mind.

a & b  # good in BOTH        == union of the holiday sets   <- settlement
a | b  # good in AT LEAST ONE
a - b  # good in a, not in b
a ^ b  # good in exactly one

Calendar.all_open([a, b, c])  # verbose alias for &
Calendar.any_open([a, b, c])  # verbose alias for |

Composites are ordinary frozen Calendar objects, so they work in offsets and as cache keys. Their bounds are the intersection of the operands', and they keep a timezone only if every operand agrees on one. Heterogeneous weekmasks (Sun–Thu against Mon–Fri) fall out correctly, because the implementation is set algebra on good days rather than merged weekmask strings.

Timezones

  1. Naive means "already in the right frame." The date part is taken literally.
  2. Aware means "an instant." Projecting it onto a calendar day needs an explicit timezone — the calendar's, or one you pass. A bare to_date(aware) raises.
  3. Offsets preserve wall-clock time and tzinfo. 2026-03-27 09:00 Paris + 1 business day is 2026-03-30 09:00 Paris, which is +71h in absolute terms across the DST transition. That is intended.

The failure this exists to prevent:

ts = pd.Timestamp("2026-07-31 23:30", tz="UTC")  # Friday
ts.date()  # 2026-07-31  Friday
ts.tz_convert("Europe/Paris").date()  # 2026-08-01  Saturday

Opt out with bcal.config.default_tz = "UTC" if your service has already decided everything is UTC. It is the library's only global state.

Named calendars

bcal.get("XNYS")  # by MIC
bcal.get("NYSE")  # or by alias
bcal.offset("2026-07-02", 1, cal="XNYS")  # '2026-07-06', skipping 3 July
bcal.is_bday("2026-04-06", cal="EUR")  # False: Easter Monday, TARGET2 closed
bcal.list(provider="quantlib")  # what came from where

Identifiers are namespaced: a bare four-letter name is an ISO-10383 MIC (XNYS, XPAR), and everything else is prefixed — country:FR, fin:TARGET2, rate:SOFR, ql: for any QuantLib class and market, wk: for workalendar, crypto:24x7. Aliases live in one declarative table, so NYSE, TARGET, EUR and SONIA all resolve.

Tenors

bcal.add_tenor("2026-01-31", "1M")  # '2026-02-28'  clamped
bcal.add_tenor("2026-02-28", "1M", eom=True)  # '2026-03-31'  end-of-month rule
bcal.add_tenor("2026-07-31", "1Y+2B", cal="XNYS")  # '2027-08-04'

Grammar: term (('+' | '-') term)* where a term is [-] INT unit and the units are D calendar days, B business days, W weeks, M months, Y years. Terms apply left to right, and the order matters — "1M+2B" is not "2B+1M".

Two month-end rules, deliberately kept apart because conflating them is where the off-by-one-day bugs live. Clamping is unconditional: 31 January plus a month is 28 February, because 31 February does not exist. The end-of-month rule is opt-in: with eom=True, a date that is the last of its month lands on the last of the target month.

Offsets as objects

from better_calendar import BDay

date(2026, 7, 31) + BDay(5)  # datetime.date(2026, 8, 7)
"2026-07-02" + BDay(1, cal="XNYS")  # '2026-07-06'
series + BDay(3, cal="XNYS")  # works, but see below

cal.offset(series, 3) is the recommended form for containers — same answer, shorter path. BDay exists for the places an offset object reads best. For pandas machinery that demands a real DateOffset (date_range, resample), use cal.to_pandas_offset(); note that it and cal.offset disagree when the start is not a business day, because pandas counts the normalisation as the move and we do not.

Importing better_calendar.integrations.pandas_ registers a .cal accessor:

trades["settles"] = trades["traded"].cal.offset(2, cal="XNYS")

Recurrences and schedules

Every dated rule is two independent decisions: how to cut the window into periods, and what to take from each one. schedule() is those two decisions and nothing else.

bcal.schedule(a, b, "M", "last FRI")  # last Friday of each month
bcal.schedule(a, b, "Q", "2 THU")  # 2nd Thursday of each quarter
bcal.schedule(a, b, "M", "last B", cal="XNYS")  # last trading day of each month
bcal.schedule(a, b, "M", "3 WED", months=(3, 6, 9, 12))  # IMM dates
bcal.schedule(a, b, "6M", "edges", cal="XNYS", roll="MF")  # a coupon schedule

every cuts: D, W, M, Q, Y, or a multiple. A bare unit aligns to the calendar, a multiple aligns to start — so "Q" and "3M" are both three months and deliberately different.

on selects: "1", "15", "last", "-2" for calendar days; "1 B", "last B" for business days; "2 THU", "last FRI", "-2 WED" for weekdays; "edges" for the period boundaries. Negative counts from the end throughout. Pass a list for several at once, or the typed Nth(-1, FRI) when a string in a config file is not what you want.

Business days and calendar days are two independent axes: on="last B" counts business days, roll= moves a result onto one. They agree more often than not, which is exactly why the difference has to be written down.

missing= says what to do when a period has no such occurrence — "skip" (the default, because February rarely has a fifth Friday), "clamp" (which makes "the 31st of each month" a one-liner), or "raise".

The named helpers are one-line spellings of the same engine, and a test pins each equivalence so the claim cannot rot:

bcal.last_weekday(a, b, FRI)  # == schedule(a, b, "M", "last FRI")
bcal.month_ends(a, b)  # == schedule(a, b, "M", "last")
bcal.month_ends(a, b, cal="XNYS")  # == schedule(a, b, "M", "last B", cal="XNYS")
bcal.quarter_ends(a, b, anchor_month=2)  # a fiscal year
bcal.imm_dates(a, b)  # == schedule(a, b, "M", "3 WED", months=…)
bcal.option_expiries(a, b, cal="XNYS")  # 3rd Friday, rolled back off a holiday
bcal.nth_day(a, b, -1)
bcal.nth_business_day(a, b, 1, cal="XNYS")

Coupon schedules

on="edges" returns the period boundaries rather than a day inside each period, with stub handling for terms that do not divide evenly:

bcal.schedule("2026-02-28", "2027-08-31", "6M", "edges", eom=True)
# ['2026-02-28', '2026-08-31', '2027-02-28', '2027-08-31']   contractual

bcal.schedule("2026-02-28", "2027-08-31", "6M", "edges", eom=True, cal="XNYS", roll="MF")
# ['2026-02-27', '2026-08-31', '2027-02-26', '2027-08-31']   when it actually pays

bcal.periods("2026-01-15", "2027-01-15", "3M", cal="XNYS", roll="MF")  # DateRange list

Nothing lets the calendar in until you pass roll. That separation is the load-bearing decision: a downstream system holding a trade booked last year needs to know its 15 March coupon is the same contractual date as yours, even if a holiday moved when it pays. If the contractual dates depended on holiday data, regenerating a snapshot would make the contract appear to change.

Stubs: short_front (the default), long_front, short_back, long_back, or none to refuse. A front stub anchors the regular grid on the end date and generates backwards, so coupons land on maturity rather than drifting away from it.

Settlement

bcal.spot("2026-07-31", "EUR")  # '2026-08-04'  T+2 in TARGET2
bcal.spot("2026-07-31", "GBP")  # '2026-07-31'  sterling settles same day
bcal.spot("2026-07-31", "CAD")  # '2026-08-04'  T+1, but Toronto is closed on the 3rd

Lags are money-market deposit conventions and live in data/spot_lags.toml, so a desk can correct a row without a release. FX spot is a property of the pair, not of a currency, and is deliberately out of scope.

Sessions

A calendar day is the interval [session_start, session_start + 24h) in the calendar's timezone — local midnight for ordinary calendars, 00:00 UTC for crypto, 17:00 New York for FX. That one definition answers the question that actually comes up: which day does this instant belong to?

ts = pd.Timestamp("2026-07-31 23:30", tz="UTC")

bcal.session_of(ts, cal="XNYS")  # date(2026, 7, 31)
bcal.session_of(ts, tz="Europe/Paris")  # date(2026, 8, 1)  already Saturday there

paris = bcal.get("XPAR")
paris.session_bounds("2026-03-29")  # 23 hours long: the clocks went forward
paris.grid("2026-07-31", "2026-07-31", "4h")  # anchored on session_start, not UTC midnight
bcal.at_times(bcal.imm_dates("2026-01-01", "2026-12-31"), ["08:00", "16:00"])

A session really is 23 or 25 hours long across a daylight-saving transition, and that is reported rather than normalised away — code that assumes 24 hours is the code this exists to correct. (European transitions fall on Sundays, so an exchange session rarely spans one; a 24/7 or session_start-shifted calendar does.)

grid is what prevents the classic mis-anchored resample: a four-hour grid built from UTC midnight cuts a Tokyo or Paris session in the wrong places.

Deliberately not here: is_open, next_open, lunch breaks, early closes. is_open() returning is_bday() would be false for every exchange with an opening bell, so the day calendar simply does not have it.

Organisation-specific calendars

A desk closes on 24 December; the euro area does not. Never fork a provider calendar for that — compose on top of it in ./better-calendar.yaml, or wherever $BETTER_CALENDAR_CONFIG points:

calendars:
  desk:paris:
    base: fin:TARGET2
    extra_holidays: ["2026-01-02", "2026-12-24"]
    tz: Europe/Paris

bcal.get("desk:paris") then resolves like any other calendar. Naming an entry after a shipped calendar (XNYS: {base: XNYS, extra_holidays: [...]}) shadows it, so existing call sites pick up the local version with no code change. See better-calendar.yaml.example.

A desk settling across two centres gives base a list and says how to combine them:

calendars:
  desk:eurgbp:
    base: [fin:TARGET2, fin:LNB]
    base_op: all_open   # good in *both*; closed as soon as either closes
    tz: Europe/Paris

base_op (all_open or any_open) is required as soon as base lists more than one calendar. It is never defaulted, because the two readings are opposites — see Calendar algebra.

TOML works identically. Reading either format needs the config extra on Python < 3.11; on 3.11+ TOML costs nothing.

Bounds

Every calendar has an explicit finite horizon and raises OutOfBoundsError outside it. Nothing is ever extrapolated — and the horizon is what the upstream can actually answer for, not what was asked:

bcal.get("XTKS").bounds  # (1997-01-01, 2100-12-31)  Tokyo data starts in 1997
bcal.get("XHKG").bounds  # (1970-01-01, 2049-12-31)  Hong Kong data ends in 2049
bcal.get("ql:Israel.TASE")  # stops in 2025: the Hebrew calendar table ends there

That last one matters. Lunar, Hebrew and Islamic holidays are tabulated rather than derived, and upstream tables end without saying so — past its table, QuantLib's Shanghai calendar quietly drops from eighteen holidays a year to one, and keeps answering "yes, business day" with total confidence. Snapshot generation detects that collapse and clips the horizon, so you get OutOfBoundsError instead of a wrong answer.

Where the data comes from

Holiday data is snapshotted, not computed at query time. Upgrading exchange-calendars can never silently move a settlement date, because runtime never calls it — it reads a file that a human reviewed and merged.

better-calendar snapshot --provider all    # regenerate; run rarely, commit the result
better-calendar diff                       # non-zero exit if any date moved
better-calendar describe rate:SOFR         # provenance: provider, version, bounds, hash
better-calendar next XNYS 2026-07-31 +5

The snapshot is one ISO date per line, one file per calendar, so a weekly CI job that regenerates against the latest upstreams opens a pull request showing exactly which dates changed — +2027-05-31 / -2027-06-01 — rather than "binary file changed".

Development

uv sync --all-extras
uv run pytest          # tests + doctests
uv run ruff check .
uv run mypy

Design notes

Ten invariants hold throughout, and the ones worth knowing before you read any code:

  • Frozen and hashable. Calendars never mutate, which is what makes them safe as cache keys and safe to share between threads.
  • Finite bounds, never extrapolated. Outside the horizon you get OutOfBoundsError, including where an upstream's own data quietly runs out.
  • Output type matches input type. date in, date out, all the way through.
  • Half-open [start, end) by default. Any other convention has to be named.
  • Set operations are named after business days, never holidays. a & b is "good in both", which is the union of the holiday sets — the vocabulary trap that makes this worth stating twice.

Doctests run as part of the suite, so every example in the docstrings is executed in CI. Roll conventions, membership and counting are cross-validated against numpy.busday_offset over a ten-year horizon, and the committed snapshot is checked day-for-day against live QuantLib, exchange-calendars and holidays over each calendar's full horizon. Those tests are marked oracle and skip cleanly without the provider extras.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

better_calendar-2.0.1.tar.gz (1.4 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

better_calendar-2.0.1-py3-none-any.whl (1.7 MB view details)

Uploaded Python 3

File details

Details for the file better_calendar-2.0.1.tar.gz.

File metadata

  • Download URL: better_calendar-2.0.1.tar.gz
  • Upload date:
  • Size: 1.4 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for better_calendar-2.0.1.tar.gz
Algorithm Hash digest
SHA256 44d7cd355e3a531fbeb459063de828789404805508148b1c3791d06eb7cd4255
MD5 5ee8f80d997393583fa88a9fa8bcd04c
BLAKE2b-256 f765c22f56f6d09f6c382dc376db55f1054fcdef45f10d94af491006c1ebe46c

See more details on using hashes here.

File details

Details for the file better_calendar-2.0.1-py3-none-any.whl.

File metadata

  • Download URL: better_calendar-2.0.1-py3-none-any.whl
  • Upload date:
  • Size: 1.7 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for better_calendar-2.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 437ba1a7dd503e8cd489b6c76228f3ed4c68016206bb0047bf9d53ab89455b8a
MD5 85f90a66cc57defb7153857ad2e320a7
BLAKE2b-256 4791dab6030ac806ee861ef265a400ad77e8821c57753219c832cf08d2b9b94c

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

2.0.1 This release

2 files

2.0.0

2 files

1.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page