The Market Calendar
Which days the market was open, measured from 8,456 real SPY bar dates rather than assumed from a rulebook — and embedded in the assembly so every host reads the same days.
Why a table and not a vendor
Every window computation in the engine and every day step in the backtester needs to know whether the market was open, and neither could be written until that had an answer. Decision 4b of the implementation plan chose a maintained MarketCalendarDays table over an FMP endpoint for four reasons: the calendar changes twice a year, it is about 250 rows a year, it is a point-in-time fact that must never silently change under a replay, and a vendor dependency on something this stable buys nothing and adds a failure mode.
The recommendation was to seed it from NYSE's published schedule. What was actually built is stricter than that.
How it was measured
seed/gen_calendar.py pulls SPY's daily bars from FMP in six calls covering 1993 to 2026 and takes the set of dates that have a bar. That set — 8,456 dates from 1993-01-29 to 2026-09-02 — is the trading-day truth for the measured span. A weekday with no bar is a closure; the NYSE holiday rules supply the note that says which holiday it was. Days after the last bar, through 2027-12-31, come from the rules alone and are marked Source = nyse-rule so nobody mistakes a forecast for a measurement.
The script then checks the two sources against each other: does the rule ever call a holiday on a day SPY traded, and does SPY ever fail to trade on a day the rule calls open? The rule and the bars disagreed on zero days. The only weekday gaps not explained by a holiday rule are the eleven special closures below, each named by hand.
The eleven special closures
| Date | Closure |
|---|---|
| 1994-04-27 | Richard Nixon funeral |
| 2001-09-11 … 14 | September 11 attacks — four days |
| 2004-06-11 | Ronald Reagan funeral |
| 2007-01-02 | Gerald Ford funeral |
| 2012-10-29, 30 | Hurricane Sandy |
| 2018-12-05 | George H. W. Bush funeral |
| 2025-01-09 | Jimmy Carter funeral |
A rule-only calendar would have called every one of these an open day. A backtester stepping through 2001-09-12 looking for a fill is not a subtle bug, but it is a silent one.
A lookup miss is an error
The CSV the script writes is embedded in the Plutus.MarketData assembly, so the backtester on a laptop, the daemon on the VM, and any future host all read the same 12,755 rows from the same file. EmbeddedMarketCalendar loads it into a sorted dictionary and answers IsTradingDay, IsEarlyClose, TradingDays(from, to), and the previous and next trading day.
The property worth naming is what it does with a date it does not have. It throws. There is no fallback to "probably a weekend" — a date outside the embedded span is an ArgumentOutOfRangeException whose message says which year to add to the seed. The alternative, guessing from the day of the week, is exactly the class of quiet wrongness the measurement was done to remove.
private (bool Trading, bool Early) Lookup(DateOnly day) =>
_days.TryGetValue(day, out var v) ? v
: throw new ArgumentOutOfRangeException(nameof(day),
$"{day:yyyy-MM-dd} is outside the market calendar ({Coverage.First:yyyy-MM-dd} … {Coverage.Last:yyyy-MM-dd}). Add the year to seed/MarketCalendarDays.csv.");What is flagged, not settled
The early-close flags are rule-derived — the day after Thanksgiving, Christmas Eve when it falls on a weekday, and July 3 from 2000 — not measured, because a daily bar does not say what time the session ended. The seed file's own header says to cross-check pre-2010 early closes against NYSE's published history before relying on CloseTimeEt. That check has not been done yet.
Phase 3 moves the source of truth into the database. Gate 3 requires the table and the embedded file to agree on every day — the calendar has to move without changing a single computed value, and that equivalence is itself the acceptance test.
Day | 2025-01-09 |
IsTradingDay | 0 |
IsEarlyClose | 0 |
CloseTimeEt | NULL |
Note | Special closure — Jimmy Carter funeral |
Source | spy-bars |
- One year added per year, by re-running the script
- The SQL seed is
REPLACE, notINSERT— re-runnable - Measured days never change; only rule days can be corrected
- Gate 2 ran on 8,455 of these days and matched SPY