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")}`; }