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%
6.0 KB · 106 lines typescript
Raw Blame History
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