import { defineConnector, parseNumber, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; import type { NormalizedObservation, RawObservation } from "@market-atlas/market-model"; import { zonedTimeToUtc } from "@market-atlas/market-model"; import { US_EQUITIES, US_ETFS } from "../_shared/us-universe.js"; const BASE = "https://api.nasdaq.com/api/quote"; const ET = "America/New_York"; const SYMBOLS = [...US_EQUITIES.map(([s]) => s), ...US_ETFS.map(([s]) => s)]; const MONTHS: Record = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }; /** * Nasdaq.com public quote JSON (the XHR behind nasdaq.com quote pages; delayed). Rights classified as * PUBLIC_RESTRICTED_REDISTRIBUTION → Market Atlas uses it as a *validator* only: it confirms or contests * the canonical price and feeds confidence, but its values are never redistributed. */ export const nasdaqQuoteApi = defineConnector({ metadata: { id: "nasdaq-quote-api", name: "Nasdaq.com — delayed quote (validation only)", version: "1.0.0", sourceId: "nasdaq-com", organization: "Nasdaq, Inc.", sourceType: "XHR", jurisdiction: "US", rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", realtimeStatus: "DELAYED", expectedLatencyMs: 15 * 60_000, supportsStreaming: false, supportsHistorical: false, assetClasses: ["EQUITY", "ETF"], exchanges: ["xnas", "xnys", "arcx"], homepage: "https://www.nasdaq.com/market-activity/stocks", description: "Delayed last sale, bid/ask and volume from nasdaq.com's public quote JSON for the curated US universe. Used for cross-validation of the canonical price only (independent Nasdaq family); values are withheld from public responses.", rightsNotes: "Nasdaq.com terms restrict redistribution: internal validation only, never displayed or streamed.", termsUrl: "https://www.nasdaq.com/terms-of-use", sourceFamily: "nasdaq", enabled: true, }, defaultSymbols: SYMBOLS, rateLimits: { "api.nasdaq.com": 0.5 }, schedule: { openMs: 5 * 60_000, closedMs: 60 * 60_000, weekendMs: 6 * 60 * 60_000, exchangeId: "xnas" }, async poll(ctx) { const out: RawObservation[] = []; const etfs = new Set(US_ETFS.map(([s]) => s)); for (const sym of ctx.watchedSymbols()) { try { const { data, response } = await ctx.http.getJson>(`${BASE}/${encodeURIComponent(sym)}/info?assetclass=${etfs.has(sym) ? "etf" : "stocks"}`, { timeoutMs: 30_000, // The Akamai front of api.nasdaq.com only answers browser-like clients. headers: { accept: "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9", origin: "https://www.nasdaq.com", referer: "https://www.nasdaq.com/", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36" }, }); if (response.notModified) continue; out.push(raw("nasdaq-quote-api", "nasdaq-com", "info", data, { symbol: sym })); } catch (err) { ctx.reportError(err, { symbol: sym }); } } return out; }, normalize(r): NormalizedBatch { const p = r.payload as { data?: { symbol?: string; primaryData?: Record; marketStatus?: string } }; const d = p?.data; const pd = d?.primaryData; if (r.kind !== "info" || !d || typeof d.symbol !== "string" || !pd) return { observations: [] }; const ts = parseNasdaqTime(String(pd.lastTradeTimestamp ?? "")); const observations: NormalizedObservation[] = []; const push = (field: NormalizedObservation["field"], v: unknown, type: NormalizedObservation["observationType"]) => { const n = parseNumber(typeof v === "string" ? v.replace(/^\$/, "") : v); if (n == null || (field !== "VOLUME" && n <= 0)) return; observations.push({ symbol: d.symbol!, field, value: n, currency: "USD", observationType: type, sourceTimestamp: ts, timestampTrust: ts ? "SOURCE" : "CONNECTOR", rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", realtimeStatus: "DELAYED", meta: { market_status: d.marketStatus, is_real_time: pd.isRealTime } }); }; push("LAST_PRICE", pd.lastSalePrice, "TRADE"); push("BID", pd.bidPrice, "QUOTE"); push("ASK", pd.askPrice, "QUOTE"); push("VOLUME", pd.volume, "TRADE"); push("CHANGE", pd.netChange, "TRADE"); return { observations }; }, fixturesDir: "fixtures", }); /** "Sep 12, 2026 4:00 PM ET" or "Sep 10, 2026" (closing) → UTC ms. */ export function parseNasdaqTime(s: string): number | null { const m = s.match(/^([A-Za-z]{3})\.?\s+(\d{1,2}),\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})\s*(AM|PM))?/i); if (!m) return null; const mo = MONTHS[m[1]!.toLowerCase()]; if (!mo) return null; let h = 16; let mi = 0; if (m[4]) { h = Number(m[4]) % 12 + (m[6]!.toUpperCase() === "PM" ? 12 : 0); mi = Number(m[5]); } return zonedTimeToUtc(`${m[3]}-${String(mo).padStart(2, "0")}-${m[2]!.padStart(2, "0")}T${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}:00`, ET); }