spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import { defineConnector, raw, type NormalizedBatch, type ProposedInstrument } 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, US_INDICES, equityHint, indexHint } from "../_shared/us-universe.js";56const BASE = "https://cdn.cboe.com/api/global/delayed_quotes/quotes";7const ET = "America/New_York";89const seeds: ProposedInstrument[] = [10 ...US_EQUITIES.map(([s, n, v]) => ({ symbol: s, hint: equityHint(s, n, v, "EQUITY"), aliases: s.includes(".") ? [s.replace(".", "-"), s.replace(".", "/"), s.replace(".", " ")] : [] })),11 ...US_ETFS.map(([s, n, v]) => ({ symbol: s, hint: equityHint(s, n, v, "ETF") })),12 ...US_INDICES.map((i) => ({ symbol: i.cboe, hint: indexHint(i), aliases: [i.symbol, `^${i.symbol}`, `.${i.symbol}`] })),13];14const SYMBOLS = seeds.map((s) => s.symbol);1516/**17 * Cboe public delayed quotes (JSON per symbol, the same endpoint the cboe.com quote pages use).18 * Adaptive polling: every 60 s per symbol during the US session, every 15 min when closed, hourly on19 * weekends. Requests are rate-limited centrally (3/s on cdn.cboe.com). Data is 15-minute delayed.20 */21export const cboeDelayedQuotes = defineConnector({22 metadata: {23 id: "cboe-delayed-quotes",24 name: "Cboe — delayed US quotes (equities, ETFs, indices)",25 version: "1.0.0",26 sourceId: "cboe",27 organization: "Cboe Global Markets",28 sourceType: "XHR",29 jurisdiction: "US",30 rightsStatus: "DELAYED",31 realtimeStatus: "DELAYED",32 expectedLatencyMs: 15 * 60_000,33 supportsStreaming: false,34 supportsHistorical: false,35 assetClasses: ["EQUITY", "ETF", "INDEX"],36 exchanges: ["xnys", "xnas", "arcx", "xcbo"],37 homepage: "https://www.cboe.com/delayed_quotes/",38 description: "Cboe's public delayed-quote JSON (last, bid/ask + sizes, open/high/low, previous close, volume, 30-day implied volatility) for a curated US universe of large caps, ETFs and the SPX/NDX/DJI/RUT/VIX indices. 15-minute delayed.",39 rightsNotes: "Delayed data displayed with attribution to Cboe Global Markets; labelled DELAYED everywhere. Not real time.",40 termsUrl: "https://www.cboe.com/about/terms-of-use/",41 sourceFamily: "cboe",42 enabled: true,43 },44 seeds,45 defaultSymbols: SYMBOLS,46 rateLimits: { "cdn.cboe.com": 1 }, // the CDN answers 429 above ~1 req/s47 schedule: { openMs: 120_000, closedMs: 15 * 60_000, weekendMs: 60 * 60_000, exchangeId: "xnys" },48 async poll(ctx) {49 const out: RawObservation[] = [];50 const symbols = ctx.watchedSymbols();51 // Sequential per symbol through the shared limiter; a failed symbol never aborts the batch.52 await Promise.all(53 symbols.map(async (sym) => {54 try {55 const { data, response } = await ctx.http.getJson<Record<string, unknown>>(`${BASE}/${encodeURIComponent(sym)}.json`, { timeoutMs: 15_000 });56 if (response.notModified) return;57 out.push(raw("cboe-delayed-quotes", "cboe", "quote", data, { symbol: sym, status: response.status, ms: response.durationMs }));58 } catch (err) {59 ctx.reportError(err, { symbol: sym });60 }61 }),62 );63 return out;64 },65 normalize(r): NormalizedBatch {66 const p = r.payload as { timestamp?: string; symbol?: string; data?: Record<string, unknown> };67 const d = p?.data;68 if (r.kind !== "quote" || !d || typeof d.symbol !== "string") return { observations: [] };69 const requested = typeof p.symbol === "string" ? p.symbol : d.symbol;70 const index = US_INDICES.find((i) => i.cboe === requested || `^${i.symbol}` === d.symbol);71 const symbol = index ? index.cboe : requested;72 const hint = index ? indexHint(index) : undefined;73 // last_trade_time is exchange local time without offset ("2026-09-11T15:59:59").74 const tradeTs = typeof d.last_trade_time === "string" ? zonedTimeToUtc(d.last_trade_time, ET) : null;75 const snapshotTs = typeof p.timestamp === "string" ? zonedTimeToUtc(p.timestamp.replace(" ", "T"), ET) : null;76 const seq = typeof d.seqno === "number" ? d.seqno : null;77 const observations: NormalizedObservation[] = [];78 const push = (field: NormalizedObservation["field"], v: unknown, ts: number | null, trust: NormalizedObservation["timestampTrust"]) => {79 const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;80 if (!Number.isFinite(n)) return;81 if ((field === "BID" || field === "ASK" || field === "LAST_PRICE" || field === "OPEN" || field === "HIGH" || field === "LOW" || field === "PREVIOUS_CLOSE") && n <= 0) return;82 observations.push({ symbol, instrumentHint: hint, field, value: n, currency: "USD", observationType: index ? "INDEX_VALUE" : field === "BID" || field === "ASK" ? "QUOTE" : "TRADE", sourceTimestamp: ts, timestampTrust: trust, sequence: seq, rightsStatus: "DELAYED", realtimeStatus: "DELAYED", meta: { security_type: d.security_type } });83 };84 push("LAST_PRICE", d.current_price, tradeTs, tradeTs ? "EXCHANGE" : "SOURCE");85 push("OPEN", d.open, tradeTs, "EXCHANGE");86 push("HIGH", d.high, tradeTs, "EXCHANGE");87 push("LOW", d.low, tradeTs, "EXCHANGE");88 push("PREVIOUS_CLOSE", d.prev_day_close, tradeTs, "EXCHANGE");89 push("VOLUME", d.volume, tradeTs, "EXCHANGE");90 push("CHANGE", d.price_change, tradeTs, "EXCHANGE");91 push("CHANGE_PERCENT", d.price_change_percent, tradeTs, "EXCHANGE");92 // Quotes (bid/ask) are as of the snapshot, not the last trade.93 push("BID", d.bid, snapshotTs, "SOURCE");94 push("ASK", d.ask, snapshotTs, "SOURCE");95 push("BID_SIZE", d.bid_size, snapshotTs, "SOURCE");96 push("ASK_SIZE", d.ask_size, snapshotTs, "SOURCE");97 push("IMPLIED_VOLATILITY", d.iv30, snapshotTs, "SOURCE");98 return { observations };99 },100 async healthCheck(ctx) {101 const { data } = await ctx.http.getJson<{ data?: { current_price?: number } }>(`${BASE}/SPY.json`);102 return { ok: typeof data?.data?.current_price === "number", detail: `SPY=${data?.data?.current_price}` };103 },104 fixturesDir: "fixtures",105});106