import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; import type { NormalizedObservation } from "@market-atlas/market-model"; import { pairObservations, planPair } from "../_shared/pairs.js"; const WS_URL = "wss://ws.bitstamp.net"; /** Bitstamp url_symbol → [base, quote]. Includes the two real fiat markets (EUR/USD, GBP/USD). */ const PAIRS: Record = { btcusd: ["BTC", "USD"], ethusd: ["ETH", "USD"], xrpusd: ["XRP", "USD"], ltcusd: ["LTC", "USD"], solusd: ["SOL", "USD"], adausd: ["ADA", "USD"], linkusd: ["LINK", "USD"], dogeusd: ["DOGE", "USD"], btceur: ["BTC", "EUR"], etheur: ["ETH", "EUR"], btcusdt: ["BTC", "USDT"], eurusd: ["EUR", "USD"], gbpusd: ["GBP", "USD"], }; /** Bitstamp public WebSocket v2: live trades (price) + top of the order book (bid/ask) per pair. */ export const bitstampWs = defineConnector({ metadata: { id: "bitstamp-ws", name: "Bitstamp — live trades & order book", version: "1.0.0", sourceId: "bitstamp", organization: "Bitstamp Ltd", sourceType: "WEBSOCKET", jurisdiction: "GB", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", expectedLatencyMs: 300, supportsStreaming: true, supportsHistorical: false, assetClasses: ["CRYPTO", "FOREX"], exchanges: ["bitstamp"], homepage: "https://www.bitstamp.net/websocket/v2/", description: "Public Bitstamp WebSocket v2: `live_trades_` (last trade, exchange microtimestamp) and `order_book_` (best bid/ask) for the major USD/EUR crypto markets and Bitstamp's fiat markets EUR/USD and GBP/USD.", rightsNotes: "Public market data displayed with attribution to Bitstamp.", termsUrl: "https://www.bitstamp.net/terms-of-use/", sourceFamily: "bitstamp", enabled: true, }, seeds: Object.entries(PAIRS).map(([sym, [b, q]]) => ({ symbol: sym, hint: planPair(b, q, "bitstamp").hint, aliases: [`${b}-${q}`, `${b}/${q}`] })), defaultSymbols: Object.keys(PAIRS), async start(ctx) { const ws = ctx.openWebSocket(WS_URL, { label: "bitstamp", staleAfterMs: 120_000, heartbeat: { intervalMs: 25_000, message: JSON.stringify({ event: "bts:heartbeat" }) }, onOpen: (sock) => { for (const p of ctx.watchedSymbols()) { sock.send({ event: "bts:subscribe", data: { channel: `live_trades_${p}` } }); sock.send({ event: "bts:subscribe", data: { channel: `order_book_${p}` } }); } }, onMessage: (data) => { let msg: any; try { msg = JSON.parse(data); } catch { return; } if (msg?.event === "trade") ctx.emit(raw("bitstamp-ws", "bitstamp", "trade", msg)); else if (msg?.event === "data" && typeof msg.channel === "string" && msg.channel.startsWith("order_book_")) ctx.emit(raw("bitstamp-ws", "bitstamp", "book", msg)); else if (msg?.event === "bts:request_reconnect") ctx.logger.info("bitstamp asked to reconnect"); }, }); ws.connect(); }, normalize(r): NormalizedBatch { const m = r.payload as { channel?: string; data?: Record }; const pairKey = typeof m.channel === "string" ? m.channel.replace(/^(live_trades_|order_book_)/, "") : ""; const pair = PAIRS[pairKey]; if (!pair || !m.data) return { observations: [] }; const [base, quote] = pair; const micro = m.data.microtimestamp; const ts = typeof micro === "string" && /^\d+$/.test(micro) ? Math.floor(Number(micro) / 1000) : null; const meta = { sourceTimestamp: ts, timestampTrust: "EXCHANGE" as const, rightsStatus: "PUBLIC_ATTRIBUTED" as const, realtimeStatus: "REALTIME" as const }; let observations: NormalizedObservation[] = []; if (r.kind === "trade") { observations = pairObservations(pairKey, base, quote, "bitstamp", { last: m.data.price }, { ...meta, sequence: typeof m.data.id === "number" ? m.data.id : null }); } else if (r.kind === "book") { const bids = m.data.bids as unknown; const asks = m.data.asks as unknown; const bid = Array.isArray(bids) && Array.isArray(bids[0]) ? bids[0][0] : undefined; const ask = Array.isArray(asks) && Array.isArray(asks[0]) ? asks[0][0] : undefined; observations = pairObservations(pairKey, base, quote, "bitstamp", { bid, ask }, meta); } return { observations }; }, fixturesDir: "fixtures", });