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
- Naive means "already in the right frame." The date part is taken literally.
- 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. - Offsets preserve wall-clock time and tzinfo.
2026-03-27 09:00 Paris + 1 business dayis2026-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.
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.
datein,dateout, 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 & bis "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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file better_calendar-2.0.0.tar.gz.
File metadata
- Download URL: better_calendar-2.0.0.tar.gz
- Upload date:
- Size: 1.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4ed79399ba55b603e9ae7e4d38454f3397bae4e9e5e2ddde3d7e04a4d785c97c
|
|
| MD5 |
8d1f656a0bf90f8ea801db2de3aaeb2d
|
|
| BLAKE2b-256 |
6f1c73940597de2e8315c16b9d6ffbf3bfc729c0a0f4ce4f7b318617935b0d16
|
File details
Details for the file better_calendar-2.0.0-py3-none-any.whl.
File metadata
- Download URL: better_calendar-2.0.0-py3-none-any.whl
- Upload date:
- Size: 1.7 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.3 {"installer":{"name":"uv","version":"0.12.3","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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f578eec6d83c3f6c1f695504ff9e72bcd75089d770b06dc24d42ed01bb717a0
|
|
| MD5 |
cc32895fb3508f6bab192ea412d96e8d
|
|
| BLAKE2b-256 |
ec34ed2d1fe4bdcb0225fcae90e880fe0803112c369a53580cf6b76a8d7ee175
|