SPB Git

Repository Public

sources.ts 9.1 KB 123 lines typescript

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
Files main
Menu

Theme