Business-day calendars

The calendar subsystem separates classification, navigation, composition, and date mapping. Every date is BusinessDay, Holiday, or Undefined; undefined means a calendar has no opinion, not that the date is a holiday.

What you will learn

  • how to classify and navigate dates asynchronously;
  • how total and sparse calendars compose;
  • how mappings adjust and shift dates;
  • how strict JSON calendars load through a managed cache.

Classify, compose, and navigate

Builtins such as ALL_WEEKDAYS are total. at(...) is sparse. Union lets an explicit special business day add an exception to the weekday rule.

import asyncio
from datetime import date

from lclang.utils.calendar import ALL_WEEKDAYS, DayType, at, nth_day_of_month


async def main() -> None:
    special = at("20240106")
    working = ALL_WEEKDAYS + special
    saturday = date(2024, 1, 6)

    assert await ALL_WEEKDAYS.get_day_type(saturday) is DayType.Holiday
    assert await special.get_day_type(date(2024, 1, 7)) is DayType.Undefined
    assert await working.get_day_type(saturday) is DayType.BusinessDay
    assert await working.next_bd(saturday) == date(2024, 1, 8)

    month_edges = nth_day_of_month(1, -1)
    assert await month_edges.get_day_type(date(2024, 2, 1)) is DayType.BusinessDay
    assert await month_edges.get_day_type(date(2024, 2, 29)) is DayType.BusinessDay


asyncio.run(main())

ALL_WEEKDAYS classifies Saturday as a holiday, while the sparse calendar explicitly classifies that Saturday as business and has no opinion on Sunday. Union lets the business opinion win, and strict navigation then advances to Monday. The signed month positions independently select February's first and last dates, including leap day.

Use +, &, -, ~, and >> for union, intersection, subtraction, reversal, and ordered fallback. Named methods provide the same operations when policy is assembled dynamically.

Adjust and shift a report date

A mapping answers where a source date moves. This example adjusts a weekend to Monday and then schedules execution one trading day later.

import asyncio
from datetime import date

from lclang.utils.calendar import ALL_DAYS, ALL_WEEKDAYS, DayType


async def main() -> None:
    report = ALL_DAYS.map_this_or_next(ALL_WEEKDAYS)
    execution = report.shift_n_days(1, ALL_WEEKDAYS)

    assert await report.map_date(date(2024, 1, 6)) == date(2024, 1, 8)
    assert await report.map_date_reverse(date(2024, 1, 8)) == (
        date(2024, 1, 6),
        date(2024, 1, 8),
    )
    assert await execution.map_date(date(2024, 1, 6)) == date(2024, 1, 9)

    report_days = await report.as_calendar()
    assert await report_days.get_day_type(date(2024, 1, 8)) is DayType.BusinessDay


asyncio.run(main())

The first operation maps Saturday to the next weekday, Monday. Reverse mapping shows that Saturday, Sunday, and Monday all converge on that target. Chaining a strict one-business-day shift starts from Monday and reaches Tuesday. Converting the first mapping into a calendar therefore marks Monday as a report business day.

Load a named strict JSON calendar

Filesystem access is explicit: the application selects the directory and constructs the loader. Tests keep that input inside a temporary directory.

import asyncio
import json
from datetime import date
from pathlib import Path
from tempfile import TemporaryDirectory

from lclang.utils.calendar import CalendarID, DayType, use_calendar_manager, use_file_system_hardcoded_calendar_loader


async def main() -> None:
    with TemporaryDirectory(prefix="lclang-calendar-tutorial-") as directory:
        root = Path(directory)
        (root / "MARKET.calendar.json").write_text(
            json.dumps(
                {
                    "business_days": ["20240102", "20240103"],
                    "holidays": ["20240101"],
                }
            ),
            encoding="utf-8",
        )
        loader = use_file_system_hardcoded_calendar_loader(root)
        manager = use_calendar_manager([loader])
        market = await manager.use_calendar(CalendarID("MARKET"))

        assert await market.get_day_type(date(2024, 1, 1)) is DayType.Holiday
        assert await market.get_day_type(date(2024, 1, 2)) is DayType.BusinessDay
        assert await market.get_day_type(date(2024, 1, 4)) is DayType.Undefined
        assert manager.cached_calendars[CalendarID("MARKET")] is market


asyncio.run(main())

The loader maps the JSON holiday and business arrays to explicit classifications. Because January 4 appears in neither array, the sparse calendar returns Undefined. The manager stores the successfully loaded instance under CalendarID("MARKET"), and the identity assertion proves later lookups can reuse it.

The JSON object must contain exactly business_days and holidays arrays of unique strict YYYYMMDD strings. Managed loads are single-flight within one event loop. Retirement removes a cached calendar and recursive dependants.

Previous: Workflow status | Next: Production patterns | Return to the series introduction