SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
5.0 KB · 96 lines typescript
Raw Blame History
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