Proposal — Market Calendar Service
Status: Draft proposal (2026-04-24), awaiting Kristerpher approval
Author: Claude (on Kristerpher's direction, 2026-04-24 review of PR #242)
Supersedes (if approved): ADR 0019 (business-day calendar — env-var choice) remains valid as the v0 stopgap
Related: PR #242 (founders grace transition, business-day compute), MarketTimeWidget, /market-hours endpoint, #254 raxx-console
1. Why this service exists
Calendar logic — what day the market is open, when it opens/closes, when a holiday shifts a trading window, whether a half-day applies — is needed in at least four places across the Raxx stack today:
- Grace-window computation (PR #242 + ADR 0019) —
compute_grace_end(entry_date, window_business_days)needs a business-day calendar. - MarketTimeWidget (Antlers header + ConfigurationBar) — already renders pre-market / open-market / after-hours / time-to-open / time-to-close via
/api/system/market-hours. - Trade-lifecycle engine (future, per #245) — entry/exit timing, roll windows, earnings blackouts, DTE calculations.
- Backtest + StrategyComparison — historical session boundaries, holiday gaps, half-days.
Today the logic is scattered: some of it is in backend_v2/api/routes/system.py's /market-hours handler, some of it lives inline in the grace-window compute planned for PR #242, some of it is implicit in how the frontend widget interpolates the session. There is no single source of truth, and no mechanism to push an ad-hoc alert (NYSE closure, circuit breaker, presidential proclamation) to the system.
Kristerpher's 2026-04-24 direction: write a proposal for a lightweight service that isolates this logic, updates daily, and can receive ad-hoc alerts from the exchanges. This doc is that proposal.
2. Goals + non-goals
Goals
- Single source of truth for all calendar questions the stack asks.
- Isolation: the grace-window engine, MarketTimeWidget, trade-lifecycle, and backtest engine all read from one place. Calendar changes (new exchange, half-day rule, DST edge case) land in one commit, not five.
- Daily refresh against an authoritative schedule source.
- Ad-hoc alerts: NYSE / NASDAQ / OPRA can close the market mid-session; when that happens, the service accepts an alert and propagates.
- Operable by a solo founder: no Redis cluster, no Kafka, no 24/7 pager. Small footprint.
- Backwards-compatible with the PR #242 env-var calendar choice — this service subsumes it when shipped, not before.
Non-goals
- Not a full market-data feed. Real-time quotes, Level 2 depth, options chains — all handled by Alpaca / future vendors (see #244). This service is calendar + session state only.
- Not a trading engine. The service answers "is the market open?" — it does not decide what to trade.
- Not a scheduler. The service provides calendar facts; the grace-window sweep (Celery in #233) still lives in Raptor and reads from this service.
- Not multi-exchange at v1. NYSE + NASDAQ first (they share the same calendar). CBOE, CME, LSE, TSX, and crypto (24/7, no calendar) come later.
3. Scope + capabilities
v1 capabilities
| Capability | Description |
|---|---|
| Session clock | Return current session state: closed / pre-market / open / after-hours / holiday / early-close. |
| Next transition | When does the current session state end? Return the next timestamp + the state we'll enter. |
| Holiday calendar | Full list of NYSE/NASDAQ holidays for a given year + whether each is a full close or half-day. |
| Business-day math | add_business_days(date, n), business_days_between(start, end), is_business_day(date). Directly replaces PR #242's env-var calendar. |
| Session boundaries for a date | Given 2026-11-27 (Black Friday half-day), return { pre_market_open, open, close: 13:00 ET, after_hours_close }. Used by backtest + lifecycle engines. |
| Ad-hoc alerts | Accept a POST from an operator (via raxx-console) or a webhook (from a future exchange-feed subscription) indicating an ad-hoc closure. Stores the alert, flags affected sessions, pushes to subscribers. |
v2+ capabilities (stretch)
- Earnings calendars (per-ticker; different upstream source)
- FOMC dates, CPI prints, other macro events that affect volatility regimes
- Options expiry calendar (third Friday, plus weeklys, plus LEAPS)
- International exchange support (LSE, TSX, CBOE/CME for futures)
4. Architecture
4a. Deployment shape
Two realistic options. I recommend Option 1 for v1 on cost + operability; Option 2 for v2 once the service has multiple independent consumers.
Option 1 — Raptor Blueprint (recommended for v1).
- A Flask Blueprint inside Raptor: backend_v2/api/blueprints/calendar/
- Isolated module boundary (dedicated package, dedicated tests, dedicated ADR list)
- Shares Raptor's Heroku dyno + Postgres
- Pro: zero additional infrastructure, zero additional cost, zero additional rotation surface
- Con: not physically separate from Raptor — if Raptor goes down, the calendar goes with it
Option 2 — Standalone service (v2+).
- Small Python service deployed on Fly.io or Render (~$5/mo)
- Dedicated repo or a subdirectory in this monorepo (services/calendar/)
- Exposes an HTTP API consumed by Raptor + Antlers + raxx-console + any future service
- Pro: independent deploy cadence, survives Raptor outages, can serve public endpoints to future consumers
- Con: new service = new rotation target, new deploy pipeline, new observability surface
Rationale for Option 1 at v1: Kristerpher called this "lightweight" explicitly, and the current dollar-cost discipline (founders-pricing rationale, Dreamhost at $22.99, no over-provisioning) means we don't want another $5/mo line item until a consumer actually needs calendar access independently of Raptor. The raxx-console and Antlers both talk to Raptor today, so they can read from this Blueprint. The service boundary is a module boundary, not a process boundary — still gets us isolation in the code sense, and Option 2 is a straightforward lift-and-shift later.
4b. Data sources
Layered by fidelity + cost:
exchange_calendarsPython package (BSD license, MIT-compatible) — covers NYSE, NASDAQ, CBOE, LSE, TSE, many more. Holiday tables + session boundaries + early-close rules. This is the Python-ecosystem standard. v1 baseline.pandas_market_calendars— similar coverage, sometimes more up-to-date on rare-event rules. Alternative ifexchange_calendarslags.- Alpaca
/v2/clockendpoint — returnsis_open+next_open+next_close. Good cross-check against the package data. v1 cross-check for current-session questions. - Polygon calendar API / Tradier calendar API — commercial; not needed at v1.
- NYSE Trader Update RSS + NASDAQ Trader RSS — authoritative for ad-hoc alerts (closures, halts, system issues). v1 for alert ingestion.
- Manual operator override — via raxx-console (future card), for cases where the RSS lag matters. v1 for emergency use.
4c. Refresh cadence
- Daily cron (04:00 ET, pre-open): pull NYSE/NASDAQ holiday + half-day data for the current + next year from
exchange_calendars. Persist to Postgres. Compare delta against prior day's snapshot; if a schedule change is detected, emit a log + optional Slack alert. - Hourly ping during trading hours: hit Alpaca
/v2/clockand cross-check against the stored schedule. Discrepancy triggers an alert. - Alert ingestion: NYSE Trader RSS polled every 2 minutes during RTH, every 15 minutes off-hours. Matching strings ("market closed", "trading halted", "NYSE declares") trigger insertion into the alerts table + notification.
4d. Storage
Three tables in Raptor's existing Postgres (or SQLite in dev):
-- Source-of-truth schedule, refreshed daily
market_sessions (
id UUID PK,
exchange TEXT NOT NULL, -- 'nyse' | 'nasdaq'
session_date DATE NOT NULL,
pre_market_open TIMESTAMPTZ,
regular_open TIMESTAMPTZ,
regular_close TIMESTAMPTZ,
after_hours_close TIMESTAMPTZ,
session_type TEXT NOT NULL, -- 'regular' | 'early_close' | 'holiday' | 'closed'
source TEXT NOT NULL, -- 'exchange_calendars' | 'manual_override' | 'alert_feed'
loaded_at TIMESTAMPTZ NOT NULL,
UNIQUE (exchange, session_date)
);
-- Holiday metadata for annotation + UI display
market_holidays (
id UUID PK,
exchange TEXT NOT NULL,
holiday_date DATE NOT NULL,
holiday_name TEXT NOT NULL, -- "Thanksgiving Day", "Christmas (observed)"
observed BOOLEAN NOT NULL, -- TRUE if the observed date differs from the actual date
full_close BOOLEAN NOT NULL,
UNIQUE (exchange, holiday_date)
);
-- Ad-hoc alerts (operator-entered or feed-derived)
market_alerts (
id UUID PK,
exchange TEXT NOT NULL,
alert_type TEXT NOT NULL, -- 'closure' | 'halt' | 'schedule_change' | 'early_close'
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ, -- NULL = indefinite
source TEXT NOT NULL, -- 'nyse_rss' | 'nasdaq_rss' | 'operator' | 'external_webhook'
raw_payload JSONB, -- full source record for forensics
message TEXT,
created_at TIMESTAMPTZ NOT NULL,
acknowledged_by TEXT, -- operator user id, if human-ack required
acknowledged_at TIMESTAMPTZ
);
Indexes: (exchange, session_date) on sessions, (exchange, effective_from) on alerts.
4e. API surface (consumed by Raptor, Antlers, future raxx-console)
Versioned prefix: /api/calendar/v1/
| Method | Path | Returns |
|---|---|---|
GET |
/session?at=<ts>&exchange=nyse |
{ state, opened_at, closes_at, next_transition_at, next_state, active_alerts[] } |
GET |
/next-open?exchange=nyse |
{ timestamp, session_type } |
GET |
/next-close?exchange=nyse |
{ timestamp, session_type } |
GET |
/is-open?at=<ts>&exchange=nyse |
{ open: bool } |
GET |
/holidays?year=2026&exchange=nyse |
[ { date, name, full_close, observed } ] |
GET |
/session-date?date=2026-11-27&exchange=nyse |
{ session_type, open, close, pre_market_open, after_hours_close } |
GET |
/business-days/add?date=2026-04-24&n=5&exchange=nyse |
{ date: "2026-05-01" } |
GET |
/business-days/between?start=...&end=...&exchange=nyse |
{ count: 12 } |
GET |
/business-days/is-business-day?date=...&exchange=nyse |
{ is_business_day: bool } |
POST |
/alerts (operator-auth, via raxx-console) |
Create an ad-hoc alert |
GET |
/alerts?since=...&active_only=true |
List alerts |
POST |
/refresh (operator-auth) |
Force immediate daily refresh |
All endpoints live under Cloudflare Access with the standard Raxx RBAC. No customer endpoint exposes write capability; reads can be cached aggressively (24h TTL on everything except /session and /alerts).
4f. Consumers
- Grace-window sweep (PR #242, Celery task in #233): replace the env-var calendar with
GET /business-days/add?date=&n=&exchange=nyse. One-line change, behind a feature flag. - MarketTimeWidget (Antlers): already consumes
/market-hours; point it at/api/calendar/v1/sessioninstead. Widget UX unchanged. - Trade-lifecycle engine (#245): reads
/session+/holidays+/business-days/addfor entry/exit windows, DTE computation, earnings blackouts. - Backtest engine: reads
/session-dateper historical day to correctly skip holidays + apply early-close timing. - raxx-console: reads
/alerts+/sessionfor operator dashboard; writes viaPOST /alertsfor manual overrides. - Future (post-launch): email/Slack subscribers can register for alert push, so operators get paged when an NYSE RSS alert lands.
5. Alert flow (the part Kristerpher specifically asked about)
[NYSE Trader RSS] [NASDAQ Trader RSS] [Operator (raxx-console)]
\ | /
\ | /
\ v /
+--> [ Calendar service /alerts ] <--+
|
v
+---------------+----------------+
| |
v v
[ market_alerts table ] [ Slack alert channel ]
| |
v v
[ /session returns [ Ops DM on high-severity alerts ]
active_alerts[] ]
|
v
[ Antlers MarketTimeWidget [ Raptor trade-lifecycle
surfaces "Market closed — engine respects alerts
FOMC emergency session" when evaluating entries ]
banner ]
Alert severity tiers (simplified per Section 11a decision): - Informational (log only) — schedule clarification, minor venue update. No banner. - User-visible banner — early close, emergency closure, halt. Banner in MarketTimeWidget.
Blocking trade initiation is NOT the alert system's job — that responsibility sits with Alpaca's real-time clock (per decision 11.3). The alert layer is communication only.
Severity is determined by keyword matching in v1 (operator can override via raxx-console). An ML classifier would be overkill.
6. Migration path
Phase 0 — v0 (status quo)
- PR #242's env-var calendar remains the business-day source.
/api/system/market-hourscontinues to serve MarketTimeWidget.- No new service.
Phase 1 — v1 Blueprint ships
- Calendar Blueprint lands in Raptor with the tables above + the
GET /sessionandGET /business-days/*endpoints. - Daily cron + hourly cross-check + RSS poller all live.
- Behind a feature flag (
ENABLE_CALENDAR_SERVICE=false), so the Blueprint is inert in prod until: - Ad-hoc alert ingestion is tested against a manual operator entry
- A known holiday (e.g. next Monday Memorial Day) is verified to flip the widget state correctly
- Flip the flag on, keep the env-var code as the fallback path behind a
USE_CALENDAR_SERVICEclient-side flag.
Phase 2 — v1.1 cutover
- MarketTimeWidget switches to
/api/calendar/v1/session. - Grace-window compute switches from env-var calendar to
/api/calendar/v1/business-days/add. - PR #242's env-var calendar is kept as a fallback for one release (rollback surface); then removed.
Phase 3 — v2 standalone service (conditional)
- If + when there's a second consumer (e.g. a Raxx analytics service) that needs calendar independent of Raptor's lifecycle, lift the Blueprint into a standalone Fly.io service.
- Trigger: second independent consumer. Not a time-based trigger.
7. Operational posture
- Observability: log + metric on every
/sessioncall, every refresh job, every alert ingestion. Grafana-equivalent board (or just structured logs inbackend_v2/observability_checks.pystyle) showing: last successful refresh, current session state, active alert count. - Runbook at
docs/security/runbooks/calendar-service.mdcovering: refresh failures, RSS feed outages, disagreement betweenexchange_calendarsand Alpaca, stuck alerts, manual override procedure. - Alerting: if daily refresh fails, Slack page after 1 retry. If
exchange_calendarsand Alpaca disagree for more than 15 minutes, Slack page. If RSS poller hasn't heard from NYSE in > 4 hours during RTH, log warn. - Graceful degradation: if the calendar Blueprint is down, Raptor callers fall back to
exchange_calendarsdirect (in-process) for read-only session questions. Write operations (alert ingestion) just queue.
8. Cost + footprint
| Line item | v1 (Blueprint) | v2 (standalone) |
|---|---|---|
| Compute | $0 (shares Raptor dyno) | ~$5/mo (Fly.io basic) |
| Storage | <10 MB/yr in Postgres | same (Fly volume or shared Postgres) |
| External data | $0 (exchange_calendars is free; Alpaca already paid for) |
same |
| Rotation surface | 0 new credentials | 1 (service-to-service token) |
| Operator time | ~2d initial, ~2h/qtr | +1d migration, ~2h/qtr |
v1 is effectively free; v2 is a $60/yr upgrade when we outgrow Option 1.
9. Risks + mitigations
exchange_calendarspackage goes unmaintained → mitigate by keeping a vendored copy of the NYSE 2026–2028 schedule in a static JSON fallback. Package maintenance is active today (last commit < 30 days at time of writing).- RSS feed silently breaks → the hourly Alpaca cross-check is the canary. If the RSS hasn't emitted anything in 24h AND Alpaca says market closed unexpectedly, page the operator.
- Alert false positives (NYSE Trader RSS emits a boilerplate "monthly maintenance" notice that matches our keyword filter) → require operator acknowledgement for L3 alerts before they gate trade initiation. L1/L2 are log/banner only.
- DST + international edge cases →
exchange_calendarshandles these. Validate against Alpaca for one DST cycle before relying in production. - Service outage blocks trading → graceful degradation (fallback to
exchange_calendarsdirect) keeps the golden path alive; only alert ingestion is soft-down during outage.
10. Acceptance criteria (if this proposal is approved)
The shipped v1 Calendar Blueprint must:
- [ ] Return
/api/calendar/v1/sessionwith accurate state for any timestamp in 2023–2028 inclusive (spot-checked against Alpaca/v2/clockand the NYSE published calendar) - [ ] Handle the 2026-11-27 Black Friday half-day correctly (closes 13:00 ET)
- [ ] Return
is_open=falsefor every 2026 NYSE holiday - [ ] Serve
/business-days/add?date=2026-12-24&n=1&exchange=nyse=2026-12-28(skipping Christmas + weekend) - [ ] Ingest a manually-entered
POST /alertsand surface it on the next/sessioncall - [ ] Refresh daily at 04:00 ET; a failed refresh pages Slack
- [ ] MarketTimeWidget consumes the v1 endpoint under a feature flag, with the old path as fallback
- [ ] Grace-window compute (PR #242) wired through with feature flag
- [ ] Runbook written
- [ ] Integration tests cover: regular open, pre-market, after-hours, holiday, half-day, DST boundary, active alert banner
11. Decisions locked (2026-04-24 — Kristerpher)
-
Cutover strategy for MarketTimeWidget — Both. Land backend-first, then cut the frontend over behind a feature flag. Progressive rollout with the flag flipped on a day we're actively watching.
-
Alert notification channel — Banner only. No customer email, no Slack paging layer for alerts. Alpaca real-time status is the go/no-go authority; the calendar service's alert layer is purely for user-visible banner copy. This simplifies the severity model — see Section 5 revision below.
-
Authority when sources disagree — Alpaca always wins the tiebreak. When
exchange_calendarsand Alpaca disagree at any level (current session OR forward-looking), Alpaca's answer is authoritative andexchange_calendarsis logged as the dissent. Rationale: everything else in the Raxx stack already sources from Alpaca; a single source of truth reduces the "which one is right" question to a non-question. -
Retention on alerts — Log for audit/review only. No customer-facing alert history UI. Alerts persist in the
market_alertstable forever (small table, valuable forensic record), but there is no customer-facing timeline or operator history browser. Logs + direct DB queries are sufficient for the cases where someone needs to look. -
raxx-console UI for alert entry at v1 — Yes. Operators get a simple form to post a manual alert (exchange, effective_from/to, alert_type, message). Matches the existing raxx-console pattern (#254 rotation UI, #212 Founders admin view).
11a. Severity model — simplified
Given the "Alpaca is the go/no-go authority" decision above, the original three-tier severity model (L1/L2/L3) collapses to two tiers:
- Informational (log only) — clarifications, boilerplate NYSE notices, things that don't affect user experience. No banner, no Slack, just a log entry for forensics.
- User-visible banner — early close, emergency closure, halt. Appears in the MarketTimeWidget banner area. Does NOT block trade initiation — Alpaca's real-time clock is still the block authority. This is communication, not enforcement.
The L3 "block-trade-initiation" tier is removed. Blocking is Alpaca's job, not the alert feed's job.
12. Recommendation
Approve the v1 Blueprint scope (Section 3, Option 1 in Section 4a, Phase 1 in Section 6). File as a new epic. Keep PR #242's env-var calendar as the v0 stopgap — it ships as planned. Calendar service then migrates PR #242's caller in Phase 2 as a low-risk follow-on.
Implementation estimate (after Kristerpher approval): ~8 engineering-days for Phase 1, one developer, behind a feature flag. Phase 2 cutover is ~2 days. Phase 3 standalone service is ~3 days if and when a second consumer appears.