Repository Public
1// Exchange adapters: public, keyless REST candles for spot pairs against USD. Facts below were checked live on 2026-11-102// from a residential connection in Canada and against the official documentation of each exchange.3//4// coinbase GET api.exchange.coinbase.com/products/{pair}/candles?granularity=&start=&end= rows [time s, low, high, open, close, volume]5// newest first, at most 300 per call, start/end inclusive, full history pageable; intervals without a trade are OMITTED;6// the still-open bar is included unmarked; Cloudflare caches an identical URL for 300 s; 10 req/s per IP (bursts 15), 429.7// bitstamp GET www.bitstamp.net/api/v2/ohlc/{pair}/?step=&limit=&end= {data:{ohlc:[{timestamp,open,high,low,close,volume}]}} (strings),8// oldest first, at most 1000 per call, `end` inclusive (when both start and end are given, start is ignored), full history;9// intervals without a trade are present with volume 0; the open bar is included unless exclude_current_candle=true;10// 400 req/s and 10 000 per 10 minutes.11// kraken GET api.kraken.com/0/public/OHLC?pair=&interval=&since= rows [time s, open, high, low, close, vwap, volume, count] (strings),12// oldest first, ONLY the 720 most recent bars whatever `since` says; the last row is always the uncommitted bar and13// `result.last` is the open time of the last committed one; errors come back as HTTP 200 {"error":[...]};14// an unpaced loop was refused ("EGeneral:Too many requests") after 10 calls in 1.3 s, and at one call every 1.5 s the15// connection stalled after about 20 calls: calls are spaced 3 s apart.16import { z } from 'zod';17import { MARKET_LISTINGS } from '@spw/core';18import { http } from '../chains/rpc.ts';19import { INTERVAL_MS, SourceError, isClosed, type Candle, type HttpJson, type Interval, type MarketSource, type MarketSymbol, type SourceId } from './types.ts';2021export const httpJson: HttpJson = async (url) => {22 const r = await http(url, { headers: { accept: 'application/json', 'user-agent': 'sp-wallet-market/1' } });23 let body: unknown = null;24 try { body = await r.json(); } catch { /* not JSON: the adapter reports the status */ }25 return { status: r.status, body };26};2728const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));29/** Serialises the calls to one exchange and keeps them `ms` apart. */30function pacer(ms: number) {31 let next = 0;32 return {33 async wait(): Promise<void> { const now = Date.now(), at = Math.max(now, next); next = at + ms; if (at > now) await sleep(at - now); },34 penalize(p: number): void { next = Math.max(next, Date.now() + p); },35 };36}3738export interface SourceOpts { get?: HttpJson; /** minimum delay between two calls; tests pass 0 */ spacingMs?: number }39interface Def {40 id: SourceId; maxBars: number; historyBars: number | null; spacingMs: number; pairs: Record<MarketSymbol, string | undefined>;41 url(pair: string, interval: Interval, range: { start: number; end: number }, now: number): string;42 /** raw bars of the answer, in any order, the open bar possibly included */43 parse(status: number, body: unknown): Candle[];44}4546function make(def: Def, opts: SourceOpts): MarketSource {47 const get = opts.get ?? httpJson, pace = pacer(opts.spacingMs ?? def.spacingMs);48 return {49 id: def.id, maxBars: def.maxBars, historyBars: def.historyBars, pairs: def.pairs, penalize: pace.penalize,50 async candles(symbol, interval, range, now) {51 const pair = def.pairs[symbol], iv = INTERVAL_MS[interval];52 if (!pair) throw new SourceError(def.id, 'unsupported', `${symbol}/USD is not listed`);53 if (range.start % iv || range.end % iv || range.end < range.start || (range.end - range.start) / iv + 1 > def.maxBars)54 throw new SourceError(def.id, 'unsupported', `bad range (at most ${def.maxBars} bars, on the ${interval} grid)`);55 await pace.wait();56 const { status, body } = await get(def.url(pair, interval, range, now));57 const rows = def.parse(status, body);58 return rows.filter((c) => c.ts >= range.start && c.ts <= range.end && isClosed(c.ts, interval, now)).sort((a, b) => a.ts - b.ts);59 },60 };61}6263const schema = <T>(id: SourceId, s: z.ZodType<T>, body: unknown): T => {64 const r = s.safeParse(body);65 if (!r.success) throw new SourceError(id, 'schema', `unexpected answer (${r.error.issues[0]?.path.join('.') ?? ''}: ${r.error.issues[0]?.message ?? 'invalid'})`);66 return r.data;67};68const msgOf = (body: unknown) => JSON.stringify(body ?? null).slice(0, 160);69/** Pair names of one exchange, from the reviewed static table of USD listings (never discovered at run time). */70const pairsOf = (id: SourceId): Record<MarketSymbol, string | undefined> => Object.fromEntries(Object.entries(MARKET_LISTINGS).flatMap(([s, l]) => (l[id] ? [[s, l[id]]] : [])));7172const CoinbaseRows = z.array(z.tuple([z.number().int(), z.number(), z.number(), z.number(), z.number(), z.number()]));73export const coinbaseSource = (opts: SourceOpts = {}): MarketSource => make({74 // 298 bars per call rather than the 300 allowed: a window ending "now" spans up to two more intervals (measured: a span75 // of 300.04 intervals is accepted, 301.04 is refused with HTTP 400)76 id: 'coinbase', maxBars: 298, historyBars: null, spacingMs: 250, pairs: pairsOf('coinbase'),77 url(pair, interval, { start, end }, now) {78 // A window that reaches the latest closed bar ends at the current second instead of the grid: the URL is then unique,79 // which keeps Cloudflare's 300 s cache from serving a copy taken before that bar was final (or before it existed).80 const to = end + 2 * INTERVAL_MS[interval] > now ? Math.floor(now / 1000) * 1000 : end;81 return `https://api.exchange.coinbase.com/products/${pair}/candles?granularity=${INTERVAL_MS[interval] / 1000}&start=${new Date(start).toISOString()}&end=${new Date(to).toISOString()}`;82 },83 parse(status, body) {84 if (status === 429) throw new SourceError('coinbase', 'rate_limited', 'too many requests');85 if (status !== 200) throw new SourceError('coinbase', 'http', `http ${status} ${msgOf(body)}`);86 return schema('coinbase', CoinbaseRows, body).map(([t, low, high, open, close, volume]) => ({ ts: t * 1000, open, high, low, close, volume }));87 },88}, opts);8990const num = z.string().regex(/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/).transform(Number);91const BitstampBody = z.object({ data: z.object({ ohlc: z.array(z.object({ timestamp: z.string().regex(/^\d+$/).transform(Number), open: num, high: num, low: num, close: num, volume: num })) }) });92export const bitstampSource = (opts: SourceOpts = {}): MarketSource => make({93 id: 'bitstamp', maxBars: 1000, historyBars: null, spacingMs: 250, pairs: pairsOf('bitstamp'),94 url: (pair, interval, { start, end }) => `https://www.bitstamp.net/api/v2/ohlc/${pair}/?step=${INTERVAL_MS[interval] / 1000}&limit=${(end - start) / INTERVAL_MS[interval] + 1}&end=${end / 1000}`,95 parse(status, body) {96 const code = String((body as { code?: unknown } | null)?.code ?? '');97 if (status === 429 || code === '400.002' || code === '400.067' || code === '400.068') throw new SourceError('bitstamp', 'rate_limited', 'too many requests');98 if (status !== 200) throw new SourceError('bitstamp', 'http', `http ${status} ${msgOf(body)}`);99 return schema('bitstamp', BitstampBody, body).data.ohlc.map((c) => ({ ts: c.timestamp * 1000, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume }));100 },101}, opts);102103const KrakenBody = z.object({ error: z.array(z.string()), result: z.record(z.string(), z.unknown()).optional() });104const KrakenRows = z.array(z.tuple([z.number().int(), num, num, num, num, num, num, z.number()]));105export const krakenSource = (opts: SourceOpts = {}): MarketSource => make({106 id: 'kraken', maxBars: 720, historyBars: 720, spacingMs: 3000, pairs: pairsOf('kraken'),107 url: (pair, interval, { start }) => `https://api.kraken.com/0/public/OHLC?pair=${pair}&interval=${INTERVAL_MS[interval] / 60_000}&since=${start / 1000 - 1}`,108 parse(status, body) {109 if (status === 429) throw new SourceError('kraken', 'rate_limited', 'too many requests');110 if (status !== 200) throw new SourceError('kraken', 'http', `http ${status} ${msgOf(body)}`);111 const b = schema('kraken', KrakenBody, body);112 if (b.error.length) throw new SourceError('kraken', /too many requests|rate limit|throttled/i.test(b.error.join(' ')) ? 'rate_limited' : 'http', b.error.join('; ').slice(0, 160));113 const keys = Object.keys(b.result ?? {}).filter((k) => k !== 'last');114 const last = schema('kraken', z.number().int(), b.result?.last);115 if (keys.length !== 1) throw new SourceError('kraken', 'schema', 'unexpected answer (one pair expected)');116 // `last` is the newest COMMITTED bar: anything after it is the bar still being built, whatever the local clock says117 return schema('kraken', KrakenRows, b.result![keys[0]!]).filter((r) => r[0] <= last).map(([t, open, high, low, close, , volume]) => ({ ts: t * 1000, open, high, low, close, volume }));118 },119}, opts);120121/** Consolidation priority: the first source that has a bar provides it (see series.ts). */122export const defaultSources = (opts: SourceOpts = {}): MarketSource[] => [coinbaseSource(opts), bitstampSource(opts), krakenSource(opts)];123