Charlie KrugThe Build Log

← All posts

Good Friday isn't a weekend, but the NYSE is still closed

Almanac parses plain-English date phrases and resolves them against a real market calendar, so 'next trading day' knows about Good Friday, Juneteenth, and observed holidays, not just Saturday and Sunday.

Ask most natural-language date libraries for "the next business day" after a Thursday and they will happily say Friday. That's right about fifty weeks a year. On the Thursday before Good Friday it is wrong, because the NYSE is closed and the real answer is Monday. Good Friday isn't a weekend. It isn't a federal holiday either. It is just a day the market doesn't trade, and a weekend-skipping heuristic has no way to know that.

Almanac is a small TypeScript library that parses phrases like next trading day, 3 business days after Thanksgiving, or the trading day before Christmas and resolves them against an actual market holiday calendar. It exists because the usual fix is ugly: you install a language parser (chrono, a date-fns plugin) for the words, a separate market-calendar package for the holidays, and then you hand-write glue to make the two agree. That glue is the part that goes subtly wrong, because it's never quite clear which layer resolves "next trading day": the parser's naive weekend skip, or the calendar's holiday-aware one.

One system, not two glued together

The whole design turns on refusing to have two layers. In Almanac the parser and the business-day math are the same system. parse() never resolves a phrase with a private weekend check; it always walks a Calendar, so holiday awareness isn't a feature you opt into, it's the only code path that exists. There is no naive fallback to disagree with.

That Calendar is the deliberate seam. Almanac ships an NYSE calendar out of the box, but nothing in the parser or the arithmetic is NYSE-specific. Every function takes a calendar argument; the NYSE one is just the default instance you reach for, not an assumption baked into the algorithm. Hand it a different exchange, or a ListCalendar built from your company's internal holiday list, and every phrase resolves identically against it:

const myCalendar = new ListCalendar([{ date: '2024-10-31', name: 'Founders Day' }]);
parse('the trading day before Founders Day', myCalendar);
// -> { ok: true, date: <2024-10-30> }

Walk the days, don't compute an offset

The tempting way to write addBusinessDays is a closed-form trick: figure out how many weeks the offset spans, multiply, adjust for the remainder. That works right up until holidays enter, and holiday sets are not uniform. They differ by exchange, by year, by jurisdiction. Juneteenth only became an NYSE holiday in 2022. So Almanac doesn't compute an offset, it walks: step one calendar day at a time, ask the calendar "is this a trading day," and count down. Slower in theory, but it's correct in practice and trivially auditable. You can read the loop and see exactly why it landed where it did.

The subtle case day-walking gets for free is observation. When a holiday falls on a Saturday, the market observes it the preceding Friday; on a Sunday, the following Monday. That's why "the trading day before Christmas" isn't just December 24th minus one: some years the 24th itself is a weekend, and the answer shifts. A formula would need special cases for all of it. A day-walk against a correct calendar just steps over whatever the calendar marks as closed.

Two things worth calling out honestly. parse() never throws: it returns { ok: true, date } or { ok: false, reason }, so a form validator, a batch job, and the playground all treat "didn't understand that" as data, and anything outside the supported phrase set comes back as false rather than a confident wrong guess. And the built-in NYSE data covers 2024 through 2027. Weekends are always recognized, but a date outside that window is treated as a trading day even if it would really be a holiday. No silent guessing at holidays for years it hasn't been told about. The whole thing ships with zero runtime dependencies, which is the point: nothing to fall out of sync with.

Try it

Open the playground and type the trading day before Christmas. Then try 3 business days after Thanksgiving, or next trading day on a Thursday in early April, and watch it skip past Good Friday to Monday. If you'd rather read the day-walk than trust it, the code is on GitHub.

Almanac is live. Free, in your browser, no signup.

This post is part of the build log: every app my automated factory ships gets written up here, honestly. Browse everything at apps.charliekrug.com. Comments are open below.

Comments

Loading comments…