import { defineConnector, extractTables, htmlFingerprints, raw, stripHtml, type NormalizedBatch, type ProposedEvent } from "@market-atlas/connector-sdk";
const URL = "https://www.nasdaqtrader.com/Trader.aspx?id=Calendar";
/** The Nasdaq schedule applies to all US equity and options markets. */
const US_EXCHANGES = ["xnas", "xnys", "xase", "arcx", "bats", "xcbo"];
const MONTHS: Record = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12 };
/**
* HTML change-detection connector: the Nasdaq Trader "Holiday Schedule" page. Section-level
* fingerprints are stored in connector state; a change emits DOCUMENT_CHANGED and the parsed
* table feeds the market-hours engine (closed days and 1:00 p.m. early closes).
*/
export const nasdaqMarketCalendar = defineConnector({
metadata: {
id: "nasdaq-market-calendar",
name: "Nasdaq Trader — US market holiday schedule (HTML)",
version: "1.0.0",
sourceId: "nasdaq-trader",
organization: "Nasdaq, Inc.",
sourceType: "HTML",
jurisdiction: "US",
rightsStatus: "PUBLIC_ATTRIBUTED",
realtimeStatus: "END_OF_DAY",
expectedLatencyMs: null,
supportsStreaming: false,
supportsHistorical: false,
assetClasses: ["EQUITY", "ETF", "INDEX"],
exchanges: US_EXCHANGES,
homepage: URL,
description: "Parses the U.S. Equity and Options Markets Holiday Schedule table (date, holiday, status) and detects any change in the page (document + section hashes). Output feeds the exchange calendar for all US venues.",
rightsNotes: "Public reference page; parsed for facts (dates), no content redistribution.",
termsUrl: "https://www.nasdaqtrader.com/Trader.aspx?id=Terms",
sourceFamily: "nasdaq",
enabled: true,
},
rateLimits: { "www.nasdaqtrader.com": 1 },
schedule: { intervalMs: 12 * 60 * 60_000 },
async poll(ctx) {
const res = await ctx.http.getText(URL, { timeoutMs: 30_000, headers: { accept: "text/html" } });
const fp = htmlFingerprints(res.text);
const prev = await ctx.state.get<{ document: string; sections: Record }>("fingerprint");
const changed = !prev || prev.document !== fp.document;
const changedSections = prev ? Object.keys(fp.sections).filter((k) => prev.sections[k] !== fp.sections[k]) : Object.keys(fp.sections);
await ctx.state.set("fingerprint", fp);
await ctx.state.set("last_checked_at", new Date().toISOString());
// Always emit the page (holidays are idempotent); mark whether the document changed so normalize can raise an event.
return [raw("nasdaq-market-calendar", "nasdaq-trader", "page", res.text, { changed, first: !prev, changed_sections: changedSections.slice(0, 10), document_hash: fp.document })];
},
normalize(r): NormalizedBatch {
if (r.kind !== "page" || typeof r.payload !== "string") return { observations: [] };
const html = r.payload;
const yearMatch = stripHtml(html).match(/Holiday Schedule (\d{4})/);
const holidays: NonNullable = [];
for (const table of extractTables(html)) {
for (const row of table) {
if (row.length < 3) continue;
const date = parseLongDate(row[0]!);
if (!date) continue;
const name = row[1]!.replace(/\*+/g, "").trim();
const status = row[2]!.trim();
const early = status.match(/(\d{1,2})(?::(\d{2}))?\s*(a|p)\.?m\.?/i);
const kind = /closed/i.test(status) ? "CLOSED" : early ? "EARLY_CLOSE" : null;
if (!kind) continue;
let closeTime: string | null = null;
if (early) {
let h = Number(early[1]);
if (early[3]!.toLowerCase() === "p" && h < 12) h += 12;
closeTime = `${String(h).padStart(2, "0")}:${early[2] ?? "00"}`;
}
const cleaned = name.replace(/early close/i, "").replace(/^[\s\-–]+|[\s\-–]+$/g, "").trim();
const label = kind === "EARLY_CLOSE" ? (cleaned && !/^u\.s\.?$/i.test(cleaned) ? `${cleaned} (early close)` : "Early close (U.S. markets)") : name;
for (const ex of US_EXCHANGES) holidays.push({ exchangeId: ex, date, name: label, kind, closeTime });
}
}
const events: ProposedEvent[] = [];
if (r.meta?.changed && !r.meta?.first) {
events.push({
type: "DOCUMENT_CHANGED",
timestamp: r.receivedAt,
severity: "NOTICE",
confidence: 1,
title: `Nasdaq Trader holiday schedule page changed${yearMatch ? ` (${yearMatch[1]})` : ""}`,
summary: `Sections changed: ${(r.meta.changed_sections as string[])?.join(", ") || "n/a"}`,
dedupeKey: `nasdaq-calendar:${r.meta.document_hash}`,
data: { url: URL, document_hash: r.meta.document_hash, changed_sections: r.meta.changed_sections, holidays_parsed: holidays.length / US_EXCHANGES.length },
});
}
return { observations: [], holidays, events, stats: { holidays: holidays.length / US_EXCHANGES.length } };
},
fixturesDir: "fixtures",
});
/** "January 19, 2026" → "2026-01-19" */
export function parseLongDate(s: string): string | null {
const m = s.trim().match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/);
if (!m) return null;
const mo = MONTHS[m[1]!.toLowerCase()];
if (!mo) return null;
return `${m[3]}-${String(mo).padStart(2, "0")}-${m[2]!.padStart(2, "0")}`;
}