Library/Market Data Engineering/Time Synchronization

D01-F03-A04 / Complete engineering topic

Exchange-Calendar Alignment

A production-minded guide to Exchange-Calendar Alignment.

D01 · MARKET DATA ENGINEERING
D01-F03-A04Canonical / Tested / Open
D01 / D01-F03
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

Practical promise. Turn an explicit exchange schedule into UTC-safe session labels without guessing from weekdays, fixed offsets, or absent rows.

NYSE core-session policy across DST, an early close, a closure, and an unsupported date

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:

  1. Venue and market: New York Stock Exchange, NYSE Tape A core equities.
  2. Calendar evidence: exact version/retrieval date and source IDs.
  3. Named time zone: America/New_York, not UTC-5.
  4. Boundary: open inclusive, close exclusive: [open, close).
  5. 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 ss with UTC open oso_s and close csc_s, event instant tet_e, and included event kinds KK:

eligible(e,s)=1{oste<cs}1{keK}.\operatorname{eligible}(e,s) = \mathbf{1}\{o_s \le t_e < c_s\} \mathbf{1}\{k_e \in K\}.

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.

ResultWhat it proves
in_sessionAn explicit session exists, the instant is inside it, and the event kind is included
outside_sessionThe date has an explicit open session, but the instant is outside its core interval
excluded_by_policyThe date/session is known, but the chosen auction or extended-hours policy excludes the event kind
closed_dateA source-backed holiday or exceptional closure exists
calendar_unsupportedThe 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.

Session boundaries before and after DST, on a real early close, and on closed or unsupported dates

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:

[2025-07-03 13:30Z,2025-07-03 17:00Z).[\text{2025-07-03 13:30Z},\text{2025-07-03 17:00Z}).

The 09:30 local open maps to 13:30Z because New York was on daylight time. With a 60-minute grid, the algorithm produces:

CellUTC intervalDurationPartial?
113:30-14:3060 minNo
214:30-15:3060 minNo
315:30-16:3060 minNo
416:30-17:0030 minYes

For interval length Δ\Delta, grid cell jj is:

Bs,j=[os+jΔ,min(os+(j+1)Δ,cs)).B_{s,j}=[o_s+j\Delta,\min(o_s+(j+1)\Delta,c_s)).

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

Rendering system map…

The implementation sequence is short because the hard work is in the data contract:

  1. Validate populated venue, market, version, retrieval, zone, and policy metadata.
  2. Require supported_dates to equal explicit sessions plus explicit closures.
  3. Reject overlapping sessions and local/UTC boundary disagreements.
  4. Convert each UTC event to a local date using the declared IANA zone.
  5. Fail closed if that date is absent.
  6. Otherwise apply closure evidence, event-kind policy, then [open, close).
  7. 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.

Calendar alignment decision flow

Purpose: keep unsupported coverage, known closures, time geometry, and package sampling policy as distinct decisions.

Rendering system map…

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 notes

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_York used 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

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.
calendarAlignment.ts
/** 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;
}
Full-height labcalendar labOpen full screen