import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; import { parseTimestamp } from "@market-atlas/market-model"; import { MAJOR_BASES } from "../_shared/crypto.js"; import { pairObservations, planPair } from "../_shared/pairs.js"; const BULLET_URL = "https://api.kucoin.com/api/v1/bullet-public"; const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}-USDT`), "EUR-USDT"]; /** * KuCoin spot ticker (`/market/ticker:`). The public WebSocket endpoint is obtained through a * keyless "bullet" token request (no account), then one connection carries every symbol. */ export const kucoinWs = defineConnector({ metadata: { id: "kucoin-ws", name: "KuCoin — spot ticker", version: "1.0.0", sourceId: "kucoin", organization: "KuCoin", sourceType: "WEBSOCKET", jurisdiction: "SC", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", expectedLatencyMs: 500, supportsStreaming: true, supportsHistorical: false, assetClasses: ["CRYPTO", "FOREX"], exchanges: ["kucoin"], homepage: "https://www.kucoin.com/docs/websocket/spot-trading/public-channels/ticker", description: "Public KuCoin spot ticker topic (last price, best bid/ask with sizes) for USDT majors and the EUR/USDT stablecoin market. Endpoint discovered via the keyless public bullet token.", rightsNotes: "Public market data displayed with attribution to KuCoin.", termsUrl: "https://www.kucoin.com/legal/terms-of-use", sourceFamily: "kucoin", enabled: true, }, seeds: SYMBOLS.map((s) => { const [b, q] = s.split("-") as [string, string]; return { symbol: s, hint: planPair(b, q, "kucoin").hint }; }), defaultSymbols: SYMBOLS, rateLimits: { "api.kucoin.com": 1 }, async start(ctx) { const res = await ctx.http.request(BULLET_URL, { method: "POST", conditional: false, timeoutMs: 15_000, headers: { "content-type": "application/json" } }); const bullet = JSON.parse(res.text) as { data?: { token?: string; instanceServers?: Array<{ endpoint: string; pingInterval?: number }> } }; const server = bullet.data?.instanceServers?.[0]; const token = bullet.data?.token; if (!server || !token) throw new Error("kucoin bullet token unavailable"); const url = `${server.endpoint}?token=${encodeURIComponent(token)}&connectId=market-atlas`; const ws = ctx.openWebSocket(url, { label: "kucoin", staleAfterMs: 60_000, heartbeat: { intervalMs: Math.max(5000, Math.min(server.pingInterval ?? 18_000, 30_000) - 3000), message: JSON.stringify({ id: "hb", type: "ping" }) }, onOpen: (sock) => sock.send({ id: "1", type: "subscribe", topic: `/market/ticker:${ctx.watchedSymbols().join(",")}`, response: true }), onMessage: (data) => { let msg: any; try { msg = JSON.parse(data); } catch { return; } if (msg?.type === "message" && typeof msg.topic === "string" && msg.topic.startsWith("/market/ticker:") && msg.data) ctx.emit(raw("kucoin-ws", "kucoin", "ticker", msg)); else if (msg?.type === "error") ctx.reportError(new Error(String(msg.data ?? "kucoin error"))); }, }); ws.connect(); }, normalize(r): NormalizedBatch { const m = r.payload as { topic?: string; data?: Record }; const d = m.data; if (r.kind !== "ticker" || typeof m.topic !== "string" || !d) return { observations: [] }; const symbol = m.topic.slice("/market/ticker:".length); const [base, quote] = symbol.split("-"); if (!base || !quote) return { observations: [] }; return { observations: pairObservations(symbol, base, quote, "kucoin", { last: d.price, bid: d.bestBid, bidSize: d.bestBidSize, ask: d.bestAsk, askSize: d.bestAskSize }, { sourceTimestamp: parseTimestamp(d.time), timestampTrust: "EXCHANGE", sequence: typeof d.sequence === "string" ? d.sequence : null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", }), }; }, fixturesDir: "fixtures", });