spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import { defineConnector, extractTables, htmlFingerprints, raw, stripHtml, type NormalizedBatch, type ProposedEvent } from "@market-atlas/connector-sdk";23const URL = "https://www.nasdaqtrader.com/Trader.aspx?id=Calendar";4/** The Nasdaq schedule applies to all US equity and options markets. */5const US_EXCHANGES = ["xnas", "xnys", "xase", "arcx", "bats", "xcbo"];6const MONTHS: Record<string, number> = { january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7, august: 8, september: 9, october: 10, november: 11, december: 12 };78/**9 * HTML change-detection connector: the Nasdaq Trader "Holiday Schedule" page. Section-level10 * fingerprints are stored in connector state; a change emits DOCUMENT_CHANGED and the parsed11 * table feeds the market-hours engine (closed days and 1:00 p.m. early closes).12 */13export const nasdaqMarketCalendar = defineConnector({14 metadata: {15 id: "nasdaq-market-calendar",16 name: "Nasdaq Trader — US market holiday schedule (HTML)",17 version: "1.0.0",18 sourceId: "nasdaq-trader",19 organization: "Nasdaq, Inc.",20 sourceType: "HTML",21 jurisdiction: "US",22 rightsStatus: "PUBLIC_ATTRIBUTED",23 realtimeStatus: "END_OF_DAY",24 expectedLatencyMs: null,25 supportsStreaming: false,26 supportsHistorical: false,27 assetClasses: ["EQUITY", "ETF", "INDEX"],28 exchanges: US_EXCHANGES,29 homepage: URL,30 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.",31 rightsNotes: "Public reference page; parsed for facts (dates), no content redistribution.",32 termsUrl: "https://www.nasdaqtrader.com/Trader.aspx?id=Terms",33 sourceFamily: "nasdaq",34 enabled: true,35 },36 rateLimits: { "www.nasdaqtrader.com": 1 },37 schedule: { intervalMs: 12 * 60 * 60_000 },38 async poll(ctx) {39 const res = await ctx.http.getText(URL, { timeoutMs: 30_000, headers: { accept: "text/html" } });40 const fp = htmlFingerprints(res.text);41 const prev = await ctx.state.get<{ document: string; sections: Record<string, string> }>("fingerprint");42 const changed = !prev || prev.document !== fp.document;43 const changedSections = prev ? Object.keys(fp.sections).filter((k) => prev.sections[k] !== fp.sections[k]) : Object.keys(fp.sections);44 await ctx.state.set("fingerprint", fp);45 await ctx.state.set("last_checked_at", new Date().toISOString());46 // Always emit the page (holidays are idempotent); mark whether the document changed so normalize can raise an event.47 return [raw("nasdaq-market-calendar", "nasdaq-trader", "page", res.text, { changed, first: !prev, changed_sections: changedSections.slice(0, 10), document_hash: fp.document })];48 },49 normalize(r): NormalizedBatch {50 if (r.kind !== "page" || typeof r.payload !== "string") return { observations: [] };51 const html = r.payload;52 const yearMatch = stripHtml(html).match(/Holiday Schedule (\d{4})/);53 const holidays: NonNullable<NormalizedBatch["holidays"]> = [];54 for (const table of extractTables(html)) {55 for (const row of table) {56 if (row.length < 3) continue;57 const date = parseLongDate(row[0]!);58 if (!date) continue;59 const name = row[1]!.replace(/\*+/g, "").trim();60 const status = row[2]!.trim();61 const early = status.match(/(\d{1,2})(?::(\d{2}))?\s*(a|p)\.?m\.?/i);62 const kind = /closed/i.test(status) ? "CLOSED" : early ? "EARLY_CLOSE" : null;63 if (!kind) continue;64 let closeTime: string | null = null;65 if (early) {66 let h = Number(early[1]);67 if (early[3]!.toLowerCase() === "p" && h < 12) h += 12;68 closeTime = `${String(h).padStart(2, "0")}:${early[2] ?? "00"}`;69 }70 const cleaned = name.replace(/early close/i, "").replace(/^[\s\-–]+|[\s\-–]+$/g, "").trim();71 const label = kind === "EARLY_CLOSE" ? (cleaned && !/^u\.s\.?$/i.test(cleaned) ? `${cleaned} (early close)` : "Early close (U.S. markets)") : name;72 for (const ex of US_EXCHANGES) holidays.push({ exchangeId: ex, date, name: label, kind, closeTime });73 }74 }75 const events: ProposedEvent[] = [];76 if (r.meta?.changed && !r.meta?.first) {77 events.push({78 type: "DOCUMENT_CHANGED",79 timestamp: r.receivedAt,80 severity: "NOTICE",81 confidence: 1,82 title: `Nasdaq Trader holiday schedule page changed${yearMatch ? ` (${yearMatch[1]})` : ""}`,83 summary: `Sections changed: ${(r.meta.changed_sections as string[])?.join(", ") || "n/a"}`,84 dedupeKey: `nasdaq-calendar:${r.meta.document_hash}`,85 data: { url: URL, document_hash: r.meta.document_hash, changed_sections: r.meta.changed_sections, holidays_parsed: holidays.length / US_EXCHANGES.length },86 });87 }88 return { observations: [], holidays, events, stats: { holidays: holidays.length / US_EXCHANGES.length } };89 },90 fixturesDir: "fixtures",91});9293/** "January 19, 2026" → "2026-01-19" */94export function parseLongDate(s: string): string | null {95 const m = s.trim().match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/);96 if (!m) return null;97 const mo = MONTHS[m[1]!.toLowerCase()];98 if (!mo) return null;99 return `${m[3]}-${String(mo).padStart(2, "0")}-${m[2]!.padStart(2, "0")}`;100}101