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
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:
compute_grace_end(entry_date, window_business_days) needs a business-day calendar./api/system/market-hours.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.
| 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. |
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.
Layered by fidelity + cost:
exchange_calendars Python 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 if exchange_calendars lags./v2/clock endpoint — returns is_open + next_open + next_close. Good cross-check against the package data. v1 cross-check for current-session questions.exchange_calendars. Persist to Postgres. Compare delta against prior day's snapshot; if a schedule change is detected, emit a log + optional Slack alert./v2/clock and cross-check against the stored schedule. Discrepancy triggers an alert.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.
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).
GET /business-days/add?date=&n=&exchange=nyse. One-line change, behind a feature flag./market-hours; point it at /api/calendar/v1/session instead. Widget UX unchanged./session + /holidays + /business-days/add for entry/exit windows, DTE computation, earnings blackouts./session-date per historical day to correctly skip holidays + apply early-close timing./alerts + /session for operator dashboard; writes via POST /alerts for manual overrides.[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.
/api/system/market-hours continues to serve MarketTimeWidget.GET /session and GET /business-days/* endpoints.ENABLE_CALENDAR_SERVICE=false), so the Blueprint is inert in prod until:USE_CALENDAR_SERVICE client-side flag./api/calendar/v1/session./api/calendar/v1/business-days/add./session call, every refresh job, every alert ingestion. Grafana-equivalent board (or just structured logs in backend_v2/observability_checks.py style) showing: last successful refresh, current session state, active alert count.docs/security/runbooks/calendar-service.md covering: refresh failures, RSS feed outages, disagreement between exchange_calendars and Alpaca, stuck alerts, manual override procedure.exchange_calendars and Alpaca disagree for more than 15 minutes, Slack page. If RSS poller hasn't heard from NYSE in > 4 hours during RTH, log warn.exchange_calendars direct (in-process) for read-only session questions. Write operations (alert ingestion) just queue.| 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.
exchange_calendars package 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).exchange_calendars handles these. Validate against Alpaca for one DST cycle before relying in production.exchange_calendars direct) keeps the golden path alive; only alert ingestion is soft-down during outage.The shipped v1 Calendar Blueprint must:
/api/calendar/v1/session with accurate state for any timestamp in 2023–2028 inclusive (spot-checked against Alpaca /v2/clock and the NYSE published calendar)is_open=false for every 2026 NYSE holiday/business-days/add?date=2026-12-24&n=1&exchange=nyse = 2026-12-28 (skipping Christmas + weekend)POST /alerts and surface it on the next /session callCutover 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_calendars and Alpaca disagree at any level (current session OR forward-looking), Alpaca's answer is authoritative and exchange_calendars is 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_alerts table 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).
Given the "Alpaca is the go/no-go authority" decision above, the original three-tier severity model (L1/L2/L3) collapses to two tiers:
The L3 "block-trade-initiation" tier is removed. Blocking is Alpaca's job, not the alert feed's job.
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.