import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; import { parseTimestamp } from "@market-atlas/market-model"; import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js"; import { pairObservations } from "../_shared/pairs.js"; const WS_URL = "wss://ws-feed.exchange.coinbase.com"; const CRYPTO_PAIRS: Array<[string, string]> = [...MAJOR_BASES.map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; /** Stablecoin/fiat markets → live FX proxies (USDC ≈ USD). */ const FX_PROXY_PAIRS: Array<[string, string]> = [["USDC", "EUR"], ["USDC", "GBP"], ["USDT", "EUR"], ["USDT", "GBP"]]; const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS]; const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); /** * Coinbase Exchange public market-data feed, `ticker` channel: one multiplexed connection for all * products, best bid/ask, last trade price, 24h stats. Exchange-generated timestamps. */ export const coinbaseWs = defineConnector({ metadata: { id: "coinbase-ws", name: "Coinbase Exchange — ticker feed", version: "1.0.0", sourceId: "coinbase", organization: "Coinbase Global, Inc.", sourceType: "WEBSOCKET", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", expectedLatencyMs: 300, supportsStreaming: true, supportsHistorical: false, assetClasses: ["CRYPTO", "FOREX"], exchanges: ["coinbase"], homepage: "https://docs.cdp.coinbase.com/exchange/docs/websocket-overview", description: "Public, unauthenticated WebSocket market-data feed of Coinbase Exchange (ticker channel). One connection carries every subscribed product.", rightsNotes: "Public market data; displayed with attribution to Coinbase Exchange. Not for commercial redistribution as a raw feed.", termsUrl: "https://www.coinbase.com/legal/market_data", sourceFamily: "coinbase", enabled: true, }, seeds: cryptoSeeds(CRYPTO_PAIRS, "coinbase", (b, q) => `${b}-${q}`), defaultSymbols: SYMBOLS, async start(ctx) { const ws = ctx.openWebSocket(WS_URL, { label: "coinbase", staleAfterMs: 90_000, heartbeat: { intervalMs: 20_000 }, onOpen: (sock) => { sock.send({ type: "subscribe", product_ids: ctx.watchedSymbols(), channels: ["ticker", "heartbeat"] }); }, onMessage: (data) => { let msg: any; try { msg = JSON.parse(data); } catch { return; } if (msg?.type === "ticker") ctx.emit(raw("coinbase-ws", "coinbase", "ticker", msg)); else if (msg?.type === "error") ctx.reportError(new Error(String(msg.message ?? "coinbase error")), { reason: msg.reason }); }, }); ws.connect(); }, normalize(r): NormalizedBatch { const m = r.payload as Record; if (r.kind !== "ticker" || typeof m.product_id !== "string") return { observations: [] }; const [base, quote] = m.product_id.split("-"); if (!base || !quote) return { observations: [] }; return { observations: pairObservations(m.product_id, base, quote, "coinbase", { last: m.price, open: m.open_24h, high: m.high_24h, low: m.low_24h, bid: m.best_bid, ask: m.best_ask, bidSize: m.best_bid_size, askSize: m.best_ask_size, volume: m.volume_24h }, { sourceTimestamp: parseTimestamp(m.time), timestampTrust: "EXCHANGE", sequence: typeof m.sequence === "number" ? m.sequence : null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", }), }; }, fixturesDir: "fixtures", });