spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import { defineConnector, parseNumber, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";2import type { NormalizedObservation, RawObservation } from "@market-atlas/market-model";3import { zonedTimeToUtc } from "@market-atlas/market-model";4import { US_EQUITIES, US_ETFS } from "../_shared/us-universe.js";56const BASE = "https://api.nasdaq.com/api/quote";7const ET = "America/New_York";8const SYMBOLS = [...US_EQUITIES.map(([s]) => s), ...US_ETFS.map(([s]) => s)];9const MONTHS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };1011/**12 * Nasdaq.com public quote JSON (the XHR behind nasdaq.com quote pages; delayed). Rights classified as13 * PUBLIC_RESTRICTED_REDISTRIBUTION → Market Atlas uses it as a *validator* only: it confirms or contests14 * the canonical price and feeds confidence, but its values are never redistributed.15 */16export const nasdaqQuoteApi = defineConnector({17 metadata: {18 id: "nasdaq-quote-api",19 name: "Nasdaq.com — delayed quote (validation only)",20 version: "1.0.0",21 sourceId: "nasdaq-com",22 organization: "Nasdaq, Inc.",23 sourceType: "XHR",24 jurisdiction: "US",25 rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION",26 realtimeStatus: "DELAYED",27 expectedLatencyMs: 15 * 60_000,28 supportsStreaming: false,29 supportsHistorical: false,30 assetClasses: ["EQUITY", "ETF"],31 exchanges: ["xnas", "xnys", "arcx"],32 homepage: "https://www.nasdaq.com/market-activity/stocks",33 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.",34 rightsNotes: "Nasdaq.com terms restrict redistribution: internal validation only, never displayed or streamed.",35 termsUrl: "https://www.nasdaq.com/terms-of-use",36 sourceFamily: "nasdaq",37 enabled: true,38 },39 defaultSymbols: SYMBOLS,40 rateLimits: { "api.nasdaq.com": 0.5 },41 schedule: { openMs: 5 * 60_000, closedMs: 60 * 60_000, weekendMs: 6 * 60 * 60_000, exchangeId: "xnas" },42 async poll(ctx) {43 const out: RawObservation[] = [];44 const etfs = new Set(US_ETFS.map(([s]) => s));45 for (const sym of ctx.watchedSymbols()) {46 try {47 const { data, response } = await ctx.http.getJson<Record<string, unknown>>(`${BASE}/${encodeURIComponent(sym)}/info?assetclass=${etfs.has(sym) ? "etf" : "stocks"}`, {48 timeoutMs: 30_000,49 // The Akamai front of api.nasdaq.com only answers browser-like clients.50 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" },51 });52 if (response.notModified) continue;53 out.push(raw("nasdaq-quote-api", "nasdaq-com", "info", data, { symbol: sym }));54 } catch (err) {55 ctx.reportError(err, { symbol: sym });56 }57 }58 return out;59 },60 normalize(r): NormalizedBatch {61 const p = r.payload as { data?: { symbol?: string; primaryData?: Record<string, unknown>; marketStatus?: string } };62 const d = p?.data;63 const pd = d?.primaryData;64 if (r.kind !== "info" || !d || typeof d.symbol !== "string" || !pd) return { observations: [] };65 const ts = parseNasdaqTime(String(pd.lastTradeTimestamp ?? ""));66 const observations: NormalizedObservation[] = [];67 const push = (field: NormalizedObservation["field"], v: unknown, type: NormalizedObservation["observationType"]) => {68 const n = parseNumber(typeof v === "string" ? v.replace(/^\$/, "") : v);69 if (n == null || (field !== "VOLUME" && n <= 0)) return;70 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 } });71 };72 push("LAST_PRICE", pd.lastSalePrice, "TRADE");73 push("BID", pd.bidPrice, "QUOTE");74 push("ASK", pd.askPrice, "QUOTE");75 push("VOLUME", pd.volume, "TRADE");76 push("CHANGE", pd.netChange, "TRADE");77 return { observations };78 },79 fixturesDir: "fixtures",80});8182/** "Sep 12, 2026 4:00 PM ET" or "Sep 10, 2026" (closing) → UTC ms. */83export function parseNasdaqTime(s: string): number | null {84 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);85 if (!m) return null;86 const mo = MONTHS[m[1]!.toLowerCase()];87 if (!mo) return null;88 let h = 16;89 let mi = 0;90 if (m[4]) {91 h = Number(m[4]) % 12 + (m[6]!.toUpperCase() === "PM" ? 12 : 0);92 mi = Number(m[5]);93 }94 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);95}96