The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryTrading CalendarA trading calendar is a versioned schedule of venue sessions, holidays, early closes, and exceptional closures.
- PrerequisiteTrading VenueA trading venue is an organized market, platform, or facility on which financial instruments can be quoted, traded, or reported.
- ImportantBusiness DayA business day is a date considered operational for a specified market, institution, currency, or contractual process.
- ImportantExchangeAn exchange is a governed trading venue that operates markets under its own rules and applicable regulation.
- ImportantMarket CloseMarket close is the venue-defined boundary or auction event that ends a specified trading session.
- ImportantMarket OpenMarket open is the venue-defined boundary or auction event that starts a specified trading session.
- ImportantTime ZoneA time zone is a governed set of civil-time offset rules used to interpret local dates and times.
- ImportantTrading DateA trading date is the venue-calendar date to which a trading session or market record is assigned.
- ImportantTrading SessionA trading session is a venue-defined period during which specified market activities are scheduled to occur.
Practical promise. Turn an explicit exchange schedule into UTC-safe session labels without guessing from weekdays, fixed offsets, or absent rows.
A time-series pipeline can be numerically correct and still be wrong by three hours. The usual cause is not arithmetic. It is an unstated calendar: a Monday assumed open, a 16:00 close applied on a half-day, or 14:30 UTC hard-coded after New York moves to daylight time.
Exchange-calendar alignment solves one auditable problem:
Under a pinned venue, market, calendar version, named time zone, and sampling policy, is this event eligible for a declared session?
That wording matters. "The NYSE" is not one universal clock: the official NYSE
hours page publishes different sections for venues and products. This article's
fixture is specifically New York Stock Exchange, NYSE Tape A core equities,
using America/New_York, IANA tzdb 2026c, and a calendar retrieved/versioned
2026-07-22.
Five declarations before one comparison
Before comparing timestamps, pin:
- Venue and market: New York Stock Exchange, NYSE Tape A core equities.
- Calendar evidence: exact version/retrieval date and source IDs.
- Named time zone:
America/New_York, notUTC-5. - Boundary: open inclusive, close exclusive:
[open, close). - Sampling policy: include the opening auction at the open; exclude the closing auction at the close and exclude pre/post-market activity.
Items 1-3 describe the evidence scope. Items 4-5 are package choices. The official page says the Tape A Core Open Auction is at 09:30 Eastern and the Closing Auction is at 16:00 Eastern. The package's decision to exclude an event at the exact 16:00 close is not an exchange claim; it is a stated sampling policy so a closing-auction event can be processed separately.
The half-open rule
For session with UTC open and close , event instant , and included event kinds :
The first indicator answers the time question. The second answers the policy question. Keeping them separate prevents an excluded auction from being mistaken for a calendar error.
| Result | What it proves |
|---|---|
in_session | An explicit session exists, the instant is inside it, and the event kind is included |
outside_session | The date has an explicit open session, but the instant is outside its core interval |
excluded_by_policy | The date/session is known, but the chosen auction or extended-hours policy excludes the event kind |
closed_date | A source-backed holiday or exceptional closure exists |
calendar_unsupported | The pinned bundle has no answer for that local date |
The last two are not synonyms. If a partial schedule lacks 2025-04-01, calling
it a holiday fabricates knowledge. The safe result is
calendar_unsupported, with eligible=false and no invented source.
Why a named zone changes the UTC clock
The official NYSE page expresses hours in Eastern Time. On Friday 2025-03-07, 09:30 New York is 14:30Z. On Monday 2025-03-10, after the U.S. spring daylight- saving transition, the same 09:30 local label is 13:30Z. The local rule remains fixed while its UTC image moves.
A fixed UTC-5 setting therefore fails after the transition. The bundle keeps
both local wall boundaries and resolved UTC instants, then validates that they
round-trip through the named zone.
Raw local times have another trap. In America/New_York,
2025-03-09T02:30:00 does not exist. 2025-11-02T01:30:00 occurs twice. The
reference implementations reject the first and require fold 0 or fold 1 for
the second. UTC event instants avoid that ambiguity; local boundaries still
need a declared zone and conversion policy.
A real historical event: 2025-07-03
This is schedule evidence, not a claim about price behavior. Official NYSE 2025 materials identified Thursday 2025-07-03 as an early-close day at 13:00 Eastern Time. The package records the core interval as:
The 09:30 local open maps to 13:30Z because New York was on daylight time. With a 60-minute grid, the algorithm produces:
| Cell | UTC interval | Duration | Partial? |
|---|---|---|---|
| 1 | 13:30-14:30 | 60 min | No |
| 2 | 14:30-15:30 | 60 min | No |
| 3 | 15:30-16:30 | 60 min | No |
| 4 | 16:30-17:00 | 30 min | Yes |
For interval length , grid cell is:
A generic 09:30-16:00 template would add three core hours that the official early-close schedule did not contain. This example makes the operational value obvious: correct bars, expected-gap labels, and daily windows all stop at the actual session boundary. It does not demonstrate prediction, economic value, or profitability.
Holidays and exceptional closures are rows, not rules of thumb
The fixture contains two different closure types:
- 2025-07-04 is an explicit Independence Day holiday.
- 2018-12-05 is an explicit exceptional closure. An official ICE/NYSE Group release announced that NYSE Group markets would close for the National Day of Mourning for President George H. W. Bush.
Neither should be derived from weekday(date). Exceptional events are exactly
why production calendars need dated notices, source provenance, versions, and
recomputation lineage.
Special open sessions follow the same discipline: add an explicit sourced row with its own type and boundaries. Do not hide an exceptional rule inside code.
Algorithm
The implementation sequence is short because the hard work is in the data contract:
- Validate populated venue, market, version, retrieval, zone, and policy metadata.
- Require
supported_datesto equal explicit sessions plus explicit closures. - Reject overlapping sessions and local/UTC boundary disagreements.
- Convert each UTC event to a local date using the declared IANA zone.
- Fail closed if that date is absent.
- Otherwise apply closure evidence, event-kind policy, then
[open, close). - Emit the reason and all calendar/session/source lineage.
Use the playground to compare causes
Open the Exchange-Calendar Alignment playground. It begins with an informative normal-session state and offers six scenarios: normal, DST-shifted, the historical early close, holiday, exceptional closure, and unsupported date.
Use Step and Back around the exact open and close. Then compare the holiday and unsupported scenarios. Both fail eligibility, but only the holiday has closure evidence. Finally compare the 09:30 local opens before and after DST; the local label stays put while the UTC axis shifts.
The lab's schedule facts and package-policy choices are labeled separately. That separation is more important than animation: it prevents a teaching convention from being repeated later as an exchange rule.
Implementation and verification
The Python implementation and TypeScript implementation consume the same calendar and 34-event corpus. Tests cover:
- open, last in-session instant, exact close, and extended-hours policy;
- standard-time versus daylight-time UTC opens;
- 2025 early-close grid clamping;
- ordinary and exceptional closures;
- unsupported weekday/weekend dates;
- nonexistent local times and both ambiguous-time folds;
- overlap, local/UTC mismatch, implicit coverage, invalid event kinds, and invalid grid intervals; and
- cross-language agreement with stored expected statuses.
Passing proves that the implementations match this definition. It does not prove the calendar is complete. The fixture supports only seven dates on purpose.
Production checklist
- Pin venue and product; do not write one "US market" clock.
- Snapshot source/version/retrieval metadata under permitted terms.
- Pin and verify the deployed tzdb release, not only its metadata label.
- Store local rules and UTC instants; validate their round trip.
- Make opening/closing auction and pre/post-market policy explicit.
- Treat absent coverage as unsupported.
- Add holidays, early closes, and exceptional sessions as sourced rows.
- Retain old calendar versions and output lineage when a schedule changes.
- Never let a calendar label overwrite the raw event.
Limitations
The package does not model symbol-specific auction eligibility, halts,
regulatory order acceptance, options late closes, crossing sessions,
settlement calendars, or multi-venue consolidation. The metadata records IANA
tzdb 2026c, but the code cannot prove which tzdb release the host operating
system or JavaScript runtime has loaded. Production deployments must verify
that separately.
Calendar eligibility also does not establish that a quoted price is valid, a feed is healthy, or an order was tradable. It partitions time for downstream algorithms; it is not an investment signal.
Summary and next topic
Correct alignment requires explicit evidence and explicit policy. Pin the
venue, version, named zone, date coverage, session type, auction treatment, and
boundary. Keep closed_date separate from calendar_unsupported. Then the
timestamp comparison becomes simple, testable, and reproducible.
Continue with Asynchronous Return Alignment, which compares the real time intervals occupied by returns after these session partitions are correct.
Primary references
Source roles, versions, access dates, and limitations are maintained in REFERENCES.md. The canonical specification is the topic README.
Rendered from the canonical Mermaid sources linked by this article.
Calendar alignment decision flow
Purpose: keep unsupported coverage, known closures, time geometry, and package sampling policy as distinct decisions.
Takeaway: an absent date is not evidence of a holiday, and an application policy is not an exchange schedule fact.
References7 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
All web sources were accessed 2026-07-22. The fixture contains a teaching transcription of schedule facts, not redistributed market data.
R01 - NYSE Holidays and Trading Hours
- Organization: New York Stock Exchange
- Source type: Official exchange hours and holiday schedule
- Publication/effective range: Current page; holiday table covers 2026-2028 at access
- Version/retrieval date: Retrieved 2026-07-22
- URL: https://www.nyse.com/trade/hours-calendars
- Jurisdiction/applicability: Named NYSE Group venues and products, United States
- Supports: NYSE Tape A pre-opening and core hours; 09:30 Core Open Auction; 16:00 Closing Auction; current 2026 holiday and early-close dates; all displayed times are Eastern Time
- Limitations: Venue/product sections differ; future schedules can change; does not authorize treating an omitted date as closed
R02 - 2025 NYSE Trading Calendar
- Organization: New York Stock Exchange / Intercontinental Exchange
- Source type: Official one-page annual calendar PDF
- Publication/effective date: 2025 calendar; dates marked correct as of 2024-12-12
- Version: 2025 yearly trading calendar
- URL: https://www.nyse.com/publicdocs/ICE_NYSE_2025_Yearly_Trading_Calendar.pdf
- Jurisdiction/applicability: NYSE calendar reference, United States
- Supports: 2025 holiday/early-close calendar context and the legend "Early Market Close at 1pm eastern"
- Limitations: The PDF says dates are subject to change; its color-coded date markings should be read with the explicit official hours page in R03
R03 - NYSE 2025-2027 Holidays and Trading Hours snapshot
- Organization: New York Stock Exchange
- Source type: Official exchange schedule page retained at an official NYSE URL
- Effective range shown: 2025-2027
- Version/retrieval date: Historical schedule view checked 2026-07-22
- URL: https://www.nyse.com/markets/hours-calendars?ecid=psgonsgcgaen1n
- Jurisdiction/applicability: Named NYSE Group markets, United States
- Supports: Explicit 2025-07-03 early close at 13:00 Eastern Time; 2025-07-04 Independence Day closure; product-specific late-session notes
- Limitations: The live site may rotate its displayed year range; preserve a source snapshot in a licensed production evidence process
R04 - IANA Time Zone Database
- Organization: Internet Assigned Numbers Authority
- Source type: Authoritative technical database
- Release:
2026c, released 2026-07-08 - URL: https://www.iana.org/time-zones
- Jurisdiction/applicability: Global civil-time rules;
America/New_Yorkused here - Supports: Named-zone history, date-specific UTC offsets, and daylight-saving transitions
- Limitations: Does not contain exchange sessions or holiday rules; the package records but cannot enforce the host runtime's installed tzdb release
R05 - New York Stock Exchange to Honor President George H. W. Bush
- Organization: Intercontinental Exchange / NYSE Group
- Source type: Official press release
- Publication date: 2018-12-01
- Version: Final published release
- URL: https://ir.theice.com/press/news-details/2018/New-York-Stock-Exchange-to-Honor-President-George-H-W-Bush/default.aspx
- Jurisdiction/applicability: New York Stock Exchange and named NYSE Group markets
- Supports: Exceptional full closure on Wednesday 2018-12-05 for the National Day of Mourning
- Limitations: Event-specific notice, not a reusable holiday rule
R06 - RFC 3339: Date and Time on the Internet
- Organization/authors: IETF; G. Klyne and C. Newman
- Source type: Internet standard
- Publication date: 2002-07
- Version: RFC 3339
- URL: https://www.rfc-editor.org/info/rfc3339/
- Jurisdiction/applicability: Global technical timestamp interchange
- Supports: Unambiguous UTC event-instant serialization
- Limitations: Does not define exchange sessions, time-zone history, or calendar coverage
R07 - RFC 9557: Date and Time on the Internet with Additional Information
- Organization/authors: IETF; U. Sharma and C. Bormann
- Source type: Standards-track RFC
- Publication date: 2024-04
- Version: RFC 9557
- URL: https://www.rfc-editor.org/rfc/rfc9557.html
- Jurisdiction/applicability: Global technical timestamp interchange
- Supports: Explicit distinction between an instant's numeric offset and additional named-zone information
- Limitations: Serialization standard, not exchange methodology
Evidence classification
- Official schedule facts: R01-R03 and R05.
- Official civil-time facts: R04.
- Timestamp representation standards: R06-R07.
- Package implementation choices:
[open, close), opening-auction inclusion, closing-auction exclusion, extended-hours exclusion, sparse supported dates, and fail-closed output. These choices are not attributed to NYSE. - Synthetic evidence:
datasets/rich_events.json; used only for deterministic mechanics and cross-language parity.
Full dependency-light reference implementations in both supported languages.
/** Align UTC events to a pinned, explicit exchange-calendar bundle. */
export type EventKind =
| "continuous"
| "opening_auction"
| "closing_auction"
| "pre_market"
| "post_market";
export type CalendarBundle = {
calendar: Record<string, any>;
sessions: Record<string, any>[];
closed_dates: Record<string, any>[];
};
const VALID_EVENT_KINDS = new Set<EventKind>([
"continuous",
"opening_auction",
"closing_auction",
"pre_market",
"post_market",
]);
function utcMs(value: unknown): number {
if (typeof value !== "string" || !value.endsWith("Z")) {
throw new Error("UTC instants must be strings ending in Z");
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) throw new Error(`invalid UTC instant: ${value}`);
return parsed;
}
function isoZ(value: number): string {
return new Date(value).toISOString();
}
type LocalParts = { year: number; month: number; day: number; hour: number; minute: number; second: number };
function parseLocalWall(value: unknown): LocalParts & { millisecond: number } {
if (typeof value !== "string") throw new Error("local wall time must be a string");
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/.exec(value);
if (!match) throw new Error(`invalid local wall time: ${value}`);
const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
const millisecond = Number((match[7] ?? "0").padEnd(3, "0"));
const probe = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
if (
probe.getUTCFullYear() !== year || probe.getUTCMonth() + 1 !== month || probe.getUTCDate() !== day ||
probe.getUTCHours() !== hour || probe.getUTCMinutes() !== minute || probe.getUTCSeconds() !== second
) throw new Error(`invalid local wall time: ${value}`);
return { year, month, day, hour, minute, second, millisecond };
}
function localPartsAt(instantMs: number, timeZone: string): LocalParts {
let parts: Intl.DateTimeFormatPart[];
try {
parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(instantMs));
} catch (error) {
throw new Error(`unknown IANA time zone: ${timeZone}`);
}
const value = (name: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === name)?.value);
return { year: value("year"), month: value("month"), day: value("day"), hour: value("hour"), minute: value("minute"), second: value("second") };
}
function sameLocal(left: LocalParts, right: LocalParts): boolean {
return left.year === right.year && left.month === right.month && left.day === right.day &&
left.hour === right.hour && left.minute === right.minute && left.second === right.second;
}
const wallCandidateCache = new Map<string, number[]>();
export function localWallToUtc(localWall: string, timeZone: string, fold?: 0 | 1): string {
const wall = parseLocalWall(localWall);
const cacheKey = `${timeZone}|${localWall}`;
let candidates = wallCandidateCache.get(cacheKey);
if (!candidates) {
const naive = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second, wall.millisecond);
candidates = [];
for (let offsetMinutes = -14 * 60; offsetMinutes <= 14 * 60; offsetMinutes += 1) {
const candidate = naive - offsetMinutes * 60_000;
if (sameLocal(localPartsAt(candidate, timeZone), wall) && !candidates.includes(candidate)) candidates.push(candidate);
}
candidates.sort((a, b) => a - b);
wallCandidateCache.set(cacheKey, candidates);
}
if (!candidates.length) throw new Error("nonexistent local wall time");
if (candidates.length > 1 && fold === undefined) throw new Error("ambiguous local wall time requires fold=0 or fold=1");
if (fold !== undefined && fold !== 0 && fold !== 1) throw new Error("fold must be 0 or 1");
return isoZ(candidates[candidates.length > 1 ? fold! : 0]);
}
function localDate(instantMs: number, timeZone: string): string {
const p = localPartsAt(instantMs, timeZone);
return `${p.year.toString().padStart(4, "0")}-${p.month.toString().padStart(2, "0")}-${p.day.toString().padStart(2, "0")}`;
}
export function validateCalendar(bundle: CalendarBundle): void {
if (!bundle || typeof bundle !== "object" || !bundle.calendar || !Array.isArray(bundle.sessions) || !Array.isArray(bundle.closed_dates)) {
throw new Error("bundle requires calendar, sessions, and closed_dates");
}
const c = bundle.calendar;
const required = ["calendar_id", "calendar_version", "retrieved_at", "venue", "market", "time_zone", "tzdb_version", "supported_dates"];
if (required.some((field) => !c[field])) throw new Error("calendar provenance fields must be populated");
if (c.boundary !== "[open, close)") throw new Error("only open-inclusive, close-exclusive calendars are accepted");
if (c.outside_coverage !== "fail_closed") throw new Error("outside_coverage must be fail_closed");
if (c.local_time_policy !== "reject_nonexistent_require_fold_for_ambiguous") throw new Error("local-time resolution policy is not supported");
if (!Array.isArray(c.supported_dates) || c.supported_dates.length !== new Set(c.supported_dates).size) throw new Error("supported_dates must be a unique list");
const sessionDates = new Set<string>();
const intervals: [number, number][] = [];
for (const session of bundle.sessions) {
const fields = ["session_id", "session_date", "local_open", "local_close", "open", "close", "session_type", "source_ids"];
if (fields.some((field) => session[field] === undefined)) throw new Error("session is missing required fields");
if (sessionDates.has(session.session_date)) throw new Error("only one core session is allowed per supported date");
if (!Array.isArray(session.source_ids) || !session.source_ids.length) throw new Error("every session needs source provenance");
const open = utcMs(session.open), close = utcMs(session.close);
if (open >= close) throw new Error("session open must precede close");
if (localWallToUtc(session.local_open, c.time_zone) !== isoZ(open)) throw new Error("local_open does not resolve to open");
if (localWallToUtc(session.local_close, c.time_zone) !== isoZ(close)) throw new Error("local_close does not resolve to close");
if (localDate(open, c.time_zone) !== session.session_date) throw new Error("session_date disagrees with local open date");
sessionDates.add(session.session_date);
intervals.push([open, close]);
}
intervals.sort((a, b) => a[0] - b[0]);
for (let index = 1; index < intervals.length; index += 1) {
if (intervals[index - 1][1] > intervals[index][0]) throw new Error("sessions overlap");
}
const closureDates = new Set<string>();
for (const closure of bundle.closed_dates) {
if (!["session_date", "closure_type", "reason", "source_ids"].every((field) => closure[field])) throw new Error("closure records require date, type, reason, and sources");
if (closureDates.has(closure.session_date) || sessionDates.has(closure.session_date)) throw new Error("a date cannot be duplicated or both open and closed");
closureDates.add(closure.session_date);
}
const explicit = new Set([...sessionDates, ...closureDates]);
if (explicit.size !== c.supported_dates.length || c.supported_dates.some((date: string) => !explicit.has(date))) {
throw new Error("supported_dates must exactly match explicit sessions and closures");
}
}
export function alignEvents(events: Record<string, any>[], bundle: CalendarBundle): Record<string, any>[] {
validateCalendar(bundle);
const c = bundle.calendar;
const sessions = new Map(bundle.sessions.map((row) => [row.session_date, row]));
const closures = new Map(bundle.closed_dates.map((row) => [row.session_date, row]));
const supported = new Set<string>(c.supported_dates);
return events.map((event) => {
if (!event || typeof event !== "object" || !event.event_id) throw new Error("every event requires event_id");
if (!VALID_EVENT_KINDS.has(event.event_kind)) throw new Error(`unsupported event_kind: ${event.event_kind}`);
const instant = utcMs(event.timestamp);
const date = localDate(instant, c.time_zone);
const common = {
...event,
local_date: date,
calendar_id: c.calendar_id,
calendar_version: c.calendar_version,
venue: c.venue,
market: c.market,
time_zone: c.time_zone,
session_id: null,
session_type: null,
eligible: false,
source_ids: [] as string[],
};
if (!supported.has(date)) return { ...common, status: "calendar_unsupported", reason: "local date is absent from the pinned calendar" };
if (closures.has(date)) {
const closure = closures.get(date)!;
return { ...common, status: "closed_date", reason: `${closure.closure_type}: ${closure.reason}`, source_ids: closure.source_ids };
}
const session = sessions.get(date)!;
const withSession = { ...common, session_id: session.session_id, session_type: session.session_type, source_ids: session.source_ids };
const open = utcMs(session.open), close = utcMs(session.close);
if (["pre_market", "post_market", "closing_auction"].includes(event.event_kind)) {
return { ...withSession, status: "excluded_by_policy", reason: `${event.event_kind} is excluded by the package sampling policy` };
}
if (event.event_kind === "opening_auction" && instant !== open) {
return { ...withSession, status: "excluded_by_policy", reason: "opening auction is eligible only at the declared open" };
}
if (open <= instant && instant < close) {
return { ...withSession, status: "in_session", eligible: true, reason: "event is inside [open, close) and its kind is included" };
}
return { ...withSession, status: "outside_session", reason: "event is outside the declared half-open core interval" };
});
}
export function sessionGrid(bundle: CalendarBundle, sessionDate: string, intervalMinutes: number): Record<string, any>[] {
validateCalendar(bundle);
if (!Number.isInteger(intervalMinutes) || intervalMinutes <= 0) throw new Error("intervalMinutes must be a positive integer");
const session = bundle.sessions.find((row) => row.session_date === sessionDate);
if (!session) throw new Error("no open session exists for this supported calendar date");
const step = intervalMinutes * 60_000;
const close = utcMs(session.close);
const rows: Record<string, any>[] = [];
for (let cursor = utcMs(session.open); cursor < close; ) {
const right = Math.min(cursor + step, close);
rows.push({
calendar_id: bundle.calendar.calendar_id,
calendar_version: bundle.calendar.calendar_version,
session_id: session.session_id,
session_date: sessionDate,
bar_open: isoZ(cursor),
bar_close: isoZ(right),
is_partial: right - cursor < step,
});
cursor = right;
}
return rows;
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.