SPB Git

Repository Public

market-store.test.ts 25.1 KB 386 lines typescript

1// Market data engine against PostgreSQL (database spw_market_test, created here if missing; never spw_test, which other2// suites reset concurrently) with scripted exchanges (no network): idempotent storage, the pipeline (incremental update,3// backfill), getSeries, assertFresh in every failing situation, the quality record and the routes.4import Fastify from 'fastify';5import { createTestDb } from './test-db.ts';6import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';7import { INTERVAL_MS, MarketDataError, SourceError, expectedLastTs, isClosed, type Candle, type Interval, type MarketSource, type MarketSymbol, type SourceId } from '../src/market/types.ts';89const H = INTERVAL_MS['1h'], D = INTERVAL_MS['1d'];10const T0 = Date.UTC(2026, 5, 1), NOW = T0 + 1000 * H + 60_000; // latest closed hourly bar: T0 + 999 h11const LAST = T0 + 999 * H;12let sql: any, store: typeof import('../src/market/store.ts'), pipe: typeof import('../src/market/pipeline.ts'), series: typeof import('../src/market/series.ts');13let routes: typeof import('../src/market/routes.ts');1415beforeAll(async () => {16  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: (await createTestDb('market_store')).url, SPW_SIGNER_SECRET: 'k'.repeat(48),17    SPW_BROADCAST: 'off', SPW_WORKERS: 'off' });18  const db2 = await import('../src/db.ts');19  sql = db2.sql;20  await db2.migrate();21  await db2.migrate(); // the schema is idempotent22  store = await import('../src/market/store.ts'); pipe = await import('../src/market/pipeline.ts'); series = await import('../src/market/series.ts');23  routes = await import('../src/market/routes.ts');24});25beforeEach(async () => { await sql`truncate candles, market_quality`; });26afterAll(async () => { await sql?.end(); });2728const mk = (ts: number, close = 100): Candle => ({ ts, open: close, high: close * 1.01, low: close * 0.99, close, volume: 5 });29/** A scripted exchange: a bar exists for every closed slot from `listedFrom`, except those in `missing`. */30function fake(id: SourceId, o: { close?: (ts: number) => number; maxBars?: number; historyBars?: number | null; listedFrom?: number; missing?: Set<number>; fail?: () => Error | null; symbols?: MarketSymbol[]; bad?: (c: Candle) => Candle } = {}) {31  const calls: { symbol: string; interval: Interval; start: number; end: number }[] = [];32  const penalties: number[] = [];33  const src: MarketSource = {34    id, maxBars: o.maxBars ?? 300, historyBars: o.historyBars ?? null, pairs: Object.fromEntries((o.symbols ?? ['BTC', 'ETH']).map((s) => [s, `${s}-USD`])),35    penalize: (ms) => { penalties.push(ms); },36    async candles(symbol, interval, range, now) {37      calls.push({ symbol, interval, ...range });38      const err = o.fail?.();39      if (err) throw err;40      const iv = INTERVAL_MS[interval], out: Candle[] = [];41      const oldest = this.historyBars === null ? -Infinity : expectedLastTs(interval, now) - (this.historyBars - 1) * iv;42      for (let ts = range.start; ts <= range.end; ts += iv) {43        if (ts < (o.listedFrom ?? T0) || ts < oldest || o.missing?.has(ts) || !isClosed(ts, interval, now)) continue;44        const c = mk(ts, (o.close ?? (() => 100))(ts) + (symbol === 'ETH' ? 1 : 0));45        out.push(o.bad ? o.bad(c) : c);46      }47      return out;48    },49  };50  return Object.assign(src, { calls, penalties });51}52const cfg = (over: Partial<import('../src/market/pipeline.ts').MarketConfig> = {}) => ({ symbols: ['BTC'] as MarketSymbol[], intervals: ['1h'] as Interval[], depthMs: { '1h': 600 * H, '1d': null, '5m': 30 * D }, ...over });53const count = async (source?: string) => (await sql`select count(*)::int as n from candles where ${source ? sql`source = ${source}` : sql`true`}`)[0].n as number;54const code = async (p: Promise<unknown>) => { try { await p; return 'ok'; } catch (e) { if (!(e instanceof MarketDataError)) throw e; return e.code; } };5556describe('storage', () => {57  it('upsert is idempotent and corrects a bar that changed', async () => {58    const bars = Array.from({ length: 2500 }, (_, i) => mk(T0 + i * H, 100 + i / 10));59    expect(await store.upsertCandles('coinbase', 'BTC', '1h', bars)).toBe(2500);60    expect(await store.upsertCandles('coinbase', 'BTC', '1h', bars)).toBe(0);61    expect(await count()).toBe(2500);62    const fixed = { ...bars[7]!, close: 100.9, high: 102 };63    expect(await store.upsertCandles('coinbase', 'BTC', '1h', [bars[6]!, fixed])).toBe(1);64    expect(await count()).toBe(2500);65    const [row] = await sql`select * from candles where ts = ${new Date(fixed.ts)}`;66    expect(row).toMatchObject({ source: 'coinbase', symbol: 'BTC', interval: '1h', open: fixed.open, high: 102, low: fixed.low, close: 100.9, volume: 5 });67    expect(row.ts.getTime()).toBe(fixed.ts);68    // the key is (source, symbol, interval, ts): the same bar time elsewhere is another row69    await store.upsertCandles('bitstamp', 'BTC', '1h', [bars[0]!]); await store.upsertCandles('coinbase', 'ETH', '1h', [bars[0]!]); await store.upsertCandles('coinbase', 'BTC', '1d', [bars[0]!]);70    expect(await count()).toBe(2503);71    expect(await store.latestTs('coinbase', 'BTC', '1h')).toBe(T0 + 2499 * H);72    expect(await store.earliestTs('coinbase', 'BTC', '1h')).toBe(T0);73    expect(await store.latestTs('kraken', 'BTC', '1h')).toBeNull();74    expect(await store.upsertCandles('coinbase', 'BTC', '1h', [])).toBe(0);75  });7677  it('floats survive the round trip exactly', async () => {78    const c: Candle = { ts: T0, open: 0.123456789012345, high: 73929.39, low: 1e-7, close: 0.1 + 0.2, volume: 250.45835878 };79    await store.upsertCandles('coinbase', 'DOGE', '1h', [c]);80    expect((await store.loadBySource('DOGE', '1h', T0)).coinbase).toEqual([c]);81  });82});8384describe('pipeline', () => {85  it('incremental update: first pass fetches the recent page, then nothing until a new bar closes', async () => {86    const a = fake('coinbase'), b = fake('bitstamp', { maxBars: 1000 });87    let now = NOW;88    const first = await pipe.updateAll([a, b], cfg(), () => now);89    expect(first).toMatchObject({ requests: 2, written: 300 + 1000, rejected: 0, errors: [] });90    expect(a.calls).toEqual([{ symbol: 'BTC', interval: '1h', start: LAST - 299 * H, end: LAST }]);91    expect(await pipe.updateAll([a, b], cfg(), () => now)).toMatchObject({ requests: 0, written: 0 });92    expect(a.calls).toHaveLength(1);93    now += H;94    expect(await pipe.updateAll([a, b], cfg(), () => now)).toMatchObject({ requests: 2, written: 2 });95    // it re-reads the two bars before the last stored one96    expect(a.calls[1]).toEqual({ symbol: 'BTC', interval: '1h', start: LAST - 2 * H, end: LAST + H });97    expect(await store.latestTs('coinbase', 'BTC', '1h')).toBe(LAST + H);98  });99100  it('incremental update catches up page by page after a long stop', async () => {101    const a = fake('coinbase', { maxBars: 100 });102    let now = NOW;103    await pipe.updateAll([a], cfg(), () => now);104    now += 250 * H;105    expect(await pipe.updateAll([a], cfg(), () => now, 2)).toMatchObject({ requests: 2 });106    expect(await store.latestTs('coinbase', 'BTC', '1h')).toBe(LAST - 2 * H + 199 * H);107    await pipe.updateAll([a], cfg(), () => now, 2);108    expect(await store.latestTs('coinbase', 'BTC', '1h')).toBe(LAST + 250 * H);109    const { gaps } = await series.getSeries('BTC', '1h', { limit: 350, priority: ['coinbase'] });110    expect(gaps).toBe(0);111  });112113  it('rejected bars are counted and never stored', async () => {114    const a = fake('coinbase', { bad: (c) => (c.ts === LAST - 5 * H ? { ...c, high: c.low / 2 } : c.ts === LAST - 6 * H ? { ...c, close: -1 } : c) });115    const r = await pipe.updateAll([a], cfg(), () => NOW);116    expect(r).toMatchObject({ rejected: 2, written: 298 });117    expect((await sql`select 1 from candles where ts in (${new Date(LAST - 5 * H)}, ${new Date(LAST - 6 * H)})`).length).toBe(0);118    expect(pipe.rejectedCount.high_below_low).toBeGreaterThanOrEqual(1);119  });120121  it('a failing exchange does not stop the others, and is left alone after a rate-limit answer', async () => {122    const down = fake('coinbase', { fail: () => new TypeError('fetch failed'), symbols: ['BTC', 'ETH', 'SOL', 'LINK', 'AAVE'] });123    const limited = fake('kraken', { fail: () => new SourceError('kraken', 'rate_limited', 'too many requests') });124    const ok = fake('bitstamp');125    const r = await pipe.updateAll([down, limited, ok], cfg({ symbols: ['BTC', 'ETH', 'SOL', 'LINK', 'AAVE'] }), () => NOW);126    expect(down.calls).toHaveLength(3); // three failures in a row: given up for this pass127    expect(limited.calls).toHaveLength(1);128    expect(limited.penalties).toEqual([30_000]);129    expect(ok.calls).toHaveLength(2);130    expect(r.errors).toHaveLength(4);131    expect(await count('bitstamp')).toBe(600);132    expect(await count('coinbase')).toBe(0);133  });134135  it('backfill pages backward to the configured depth, within a request budget, and resumes', async () => {136    const a = fake('coinbase', { maxBars: 100 });137    await pipe.updateAll([a], cfg(), () => NOW); // 100 most recent bars138    const done = new Set<string>();139    const one = await pipe.backfill([a], cfg(), { done, maxRequests: 2 }, () => NOW);140    expect(one).toMatchObject({ requests: 2, written: 200, complete: false });141    expect(await count()).toBe(300);142    const two = await pipe.backfill([a], cfg(), { done, maxRequests: 50 }, () => NOW);143    expect(two.complete).toBe(true);144    // depth 600 h from NOW (T0 + 1000 h + 1 min): the oldest bar wanted opens at T0 + 401 h145    expect(await store.earliestTs('coinbase', 'BTC', '1h')).toBe(T0 + 401 * H);146    expect(await count()).toBe(599);147    expect((await series.getSeries('BTC', '1h', { limit: 1000, priority: ['coinbase'] })).gaps).toBe(0);148    // nothing more to do, with or without the memory of the previous passes149    a.calls.length = 0;150    expect(await pipe.backfill([a], cfg(), { done, maxRequests: 50 }, () => NOW)).toMatchObject({ requests: 0, complete: true });151    expect(await pipe.backfill([a], cfg(), { done: new Set(), maxRequests: 50 }, () => NOW)).toMatchObject({ requests: 0, complete: true });152    expect(a.calls).toHaveLength(0);153    // running everything again changes nothing154    const before = await count();155    await pipe.updateAll([a], cfg(), () => NOW);156    expect(await count()).toBe(before);157  });158159  it('backfill of "all available" stops at the listing date; a source with a short memory is not asked for more', async () => {160    const a = fake('coinbase', { maxBars: 100, listedFrom: T0 + 730 * H });161    const k = fake('kraken', { maxBars: 720, historyBars: 720 });162    const c = cfg({ depthMs: { '1h': null, '1d': null, '5m': null } });163    await pipe.updateAll([a, k], c, () => NOW);164    expect(k.calls).toHaveLength(1);165    expect(await count('kraken')).toBe(720);166    const r = await pipe.backfill([a, k], c, { done: new Set(), maxRequests: 50 }, () => NOW);167    expect(r.complete).toBe(true);168    expect(k.calls).toHaveLength(1);169    expect(await store.earliestTs('coinbase', 'BTC', '1h')).toBe(T0 + 730 * H);170    expect(a.calls.at(-1)).toMatchObject({ end: T0 + 699 * H }); // the empty page that proved there is nothing older171    expect(await count('coinbase')).toBe(270);172  });173174  it('backfill respects a deadline', async () => {175    const a = fake('coinbase', { maxBars: 100 });176    let t = NOW;177    const r = await pipe.backfill([a], cfg(), { done: new Set(), maxRequests: 50, deadline: NOW + 2500 }, () => (t += 1000));178    expect(r.complete).toBe(false);179    expect(r.requests).toBeLessThan(3);180  });181});182183describe('getSeries', () => {184  beforeEach(async () => {185    await store.upsertCandles('coinbase', 'BTC', '1h', [0, 1, 2, 4, 5].map((i) => mk(T0 + i * H, 100 + i)));186    await store.upsertCandles('bitstamp', 'BTC', '1h', [0, 1, 2, 3, 4, 5].map((i) => mk(T0 + i * H, 200 + i)));187    await store.upsertCandles('kraken', 'BTC', '1h', [3, 4, 5, 8].map((i) => mk(T0 + i * H, 300 + i)));188    await store.upsertCandles('coinbase', 'ETH', '1h', [mk(T0, 9)]);189    await store.upsertCandles('coinbase', 'BTC', '1d', [mk(T0, 7)]);190  });191192  it('returns one consolidated series: whole bars of the best-ranked source, labelled', async () => {193    const s = await series.getSeries('BTC', '1h', { limit: 100 });194    expect(s.bars.map((b) => [(b.ts - T0) / H, b.source, b.close])).toEqual([[0, 'coinbase', 100], [1, 'coinbase', 101], [2, 'coinbase', 102], [3, 'bitstamp', 203], [4, 'coinbase', 104], [5, 'coinbase', 105], [8, 'kraken', 308]]);195    expect(s.bars[3]).toEqual({ ...mk(T0 + 3 * H, 203), source: 'bitstamp' });196    expect(s).toMatchObject({ symbol: 'BTC', interval: '1h', gaps: 2, provided: { coinbase: 5, bitstamp: 1, kraken: 1 }, priority: ['coinbase', 'bitstamp', 'kraken'] });197  });198199  it('applies limit and until on the consolidated bars', async () => {200    expect((await series.getSeries('BTC', '1h', { limit: 3 })).bars.map((b) => (b.ts - T0) / H)).toEqual([4, 5, 8]);201    const s = await series.getSeries('BTC', '1h', { limit: 3, until: T0 + 5 * H });202    expect(s.bars.map((b) => (b.ts - T0) / H)).toEqual([3, 4, 5]);203    expect(s.gaps).toBe(0);204    expect((await series.getSeries('BTC', '1h', { limit: 2, until: T0 + 3 * H + 1 })).bars.map((b) => b.source)).toEqual(['coinbase', 'bitstamp']);205    expect((await series.getSeries('BTC', '1h', { limit: 5, until: T0 - 1 })).bars).toEqual([]);206    expect((await series.getSeries('BTC', '1h', { limit: 100, priority: ['kraken', 'bitstamp'] })).bars.map((b) => b.source)).toEqual(['bitstamp', 'bitstamp', 'bitstamp', 'kraken', 'kraken', 'kraken', 'kraken']);207    expect((await series.getSeries('BTC', '1d', { limit: 10 })).bars).toHaveLength(1);208    expect((await series.getSeries('SOL', '1h', { limit: 10 })).bars).toEqual([]);209  });210211  it('refuses an unknown symbol or interval', async () => {212    expect(await code(series.getSeries('NOPE' as never, '1h', { limit: 1 }))).toBe('unsupported');213    expect(await code(series.getSeries('BTC', '2h' as never, { limit: 1 }))).toBe('unsupported');214  });215});216217describe('assertFresh', () => {218  const fill = async (o: Partial<Record<SourceId, Parameters<typeof fake>[1]>> = {}, now = NOW) => {219    const srcs = (['coinbase', 'bitstamp', 'kraken'] as SourceId[]).map((id) => fake(id, o[id]));220    await pipe.updateAll(srcs, cfg(), () => now);221    return srcs;222  };223224  it('passes when the series is fresh, complete and confirmed, and returns the report', async () => {225    await fill({ bitstamp: { close: () => 100.2 } });226    const r = await series.assertFresh('BTC', '1h', NOW);227    expect(r).toMatchObject({ status: 'ok', fresh: true, lastTs: LAST, gaps: 0, bars: 200 });228    expect(r.maxDeviationBps).toBe(0); // coinbase is confirmed exactly by kraken; bitstamp's 20 bps are recorded in `agreement`229    expect(r.agreement.at(-1)!.closes).toMatchObject({ coinbase: 100, bitstamp: 100.2 });230  });231232  it('stale: the latest bar is missing for more than one bar', async () => {233    await fill();234    expect(await code(series.assertFresh('BTC', '1h', NOW + H))).toBe('ok');235    expect(await code(series.assertFresh('BTC', '1h', NOW + 2 * H))).toBe('stale');236    expect(await code(series.assertFresh('BTC', '1h', NOW + H, { maxLagBars: 0 }))).toBe('stale');237    const e = await series.assertFresh('BTC', '1h', NOW + 5 * H).catch((x) => x);238    expect(e).toBeInstanceOf(MarketDataError);239    expect(e.report).toMatchObject({ status: 'stale', lagBars: 5, lastTs: LAST });240    expect(e.message).toContain('5 bars behind');241  });242243  it('gap: a hole inside the requested window, on every source', async () => {244    const missing = new Set([LAST - 20 * H]);245    await fill({ coinbase: { missing }, bitstamp: { missing }, kraken: { missing } });246    expect(await code(series.assertFresh('BTC', '1h', NOW))).toBe('gap');247    expect(await code(series.assertFresh('BTC', '1h', NOW, { lookback: 20 }))).toBe('ok');248    expect(await code(series.assertFresh('BTC', '1h', NOW, { lookback: 21 }))).toBe('gap');249  });250251  it('a hole on the primary source alone is not a gap: the next source provides that bar', async () => {252    await fill({ coinbase: { missing: new Set([LAST - 20 * H]) } });253    expect(await series.assertFresh('BTC', '1h', NOW)).toMatchObject({ provided: { coinbase: 199, bitstamp: 1 } });254    expect((await series.getSeries('BTC', '1h', { limit: 21 })).bars[0]).toMatchObject({ ts: LAST - 20 * H, source: 'bitstamp' });255  });256257  it('insufficient history: the window asked for is longer than what is stored', async () => {258    await fill();259    expect(await code(series.assertFresh('BTC', '1h', NOW, { lookback: 300 }))).toBe('ok');260    expect(await code(series.assertFresh('BTC', '1h', NOW, { lookback: 301 }))).toBe('insufficient_history');261  });262263  it('disagreement: sources apart by more than 50 bps on one of the last bars', async () => {264    await fill({ coinbase: { close: (ts) => (ts === LAST - H ? 100.6 : 100) } }); // the bar the series uses is the one that is off265    const e = await series.assertFresh('BTC', '1h', NOW).catch((x) => x);266    expect(e).toMatchObject({ code: 'disagreement' });267    expect(e.report.agreement[1]).toMatchObject({ ts: LAST - H, closes: { coinbase: 100.6, bitstamp: 100, kraken: 100 } });268    expect(await code(series.assertFresh('BTC', '1h', NOW, { maxDeviationBps: 100 }))).toBe('ok');269    expect(await code(series.assertFresh('BTC', '1h', NOW, { agreeBars: 1 }))).toBe('ok');270  });271272  it('single source down: with two sources configured, the survivor alone is refused', async () => {273    const two = { priority: ['coinbase', 'bitstamp'] as SourceId[] };274    const [a, b] = await fill();275    expect(await code(series.assertFresh('BTC', '1h', NOW, two))).toBe('ok');276    const now = NOW + 2 * H;277    await pipe.updateAll([a!, fake('bitstamp', { fail: () => new TypeError('fetch failed') })], cfg(), () => now);278    expect(await store.latestTs('coinbase', 'BTC', '1h')).toBe(LAST + 2 * H);279    expect(await store.latestTs('bitstamp', 'BTC', '1h')).toBe(LAST);280    expect(await code(series.assertFresh('BTC', '1h', now, two))).toBe('insufficient_sources');281    // and the other way round: the primary is down, the fallback serves the bars but nobody confirms them282    await sql`truncate candles`;283    await fill();284    await pipe.updateAll([fake('coinbase', { fail: () => new TypeError('fetch failed') }), b!], cfg(), () => now);285    expect(await code(series.assertFresh('BTC', '1h', now, two))).toBe('insufficient_sources');286    expect((await series.getSeries('BTC', '1h', { limit: 1, ...two })).bars[0]).toMatchObject({ ts: LAST + 2 * H, source: 'bitstamp' });287    // once it is back and has caught up, the series is usable again288    await pipe.updateAll([fake('coinbase'), b!], cfg(), () => now);289    expect(await code(series.assertFresh('BTC', '1h', now, two))).toBe('ok');290  });291292  it('with three sources one may be down, not two', async () => {293    const [a, b, k] = await fill();294    const now = NOW + 3 * H, dead = (id: SourceId) => fake(id, { fail: () => new TypeError('fetch failed') });295    await pipe.updateAll([dead('coinbase'), b!, k!], cfg(), () => now);296    expect(await series.assertFresh('BTC', '1h', now)).toMatchObject({ status: 'ok', provided: { coinbase: 197, bitstamp: 3 } });297    await pipe.updateAll([a!, dead('bitstamp'), dead('kraken')], cfg(), () => now + H);298    expect(await code(series.assertFresh('BTC', '1h', now + H))).toBe('insufficient_sources');299  });300301  it('no data, unknown series, and a database that does not answer: never a yes', async () => {302    expect(await code(series.assertFresh('BTC', '1h', NOW))).toBe('no_data');303    await fill();304    expect(await code(series.assertFresh('ETH', '1d', NOW))).toBe('no_data');305    expect(await code(series.assertFresh('NOPE' as never, '1h', NOW))).toBe('unsupported');306    expect(await code(series.assertFresh('BTC', '1w' as never, NOW))).toBe('unsupported');307    await sql`alter table candles rename to candles_gone`;308    try { await expect(series.assertFresh('BTC', '1h', NOW)).rejects.toThrow(/candles/); }309    finally { await sql`alter table candles_gone rename to candles`; }310  });311312  it('uses the stored bars, not the cached quality record', async () => {313    await fill();314    await pipe.recomputeQuality(cfg(), ['coinbase', 'bitstamp', 'kraken'], NOW);315    expect((await store.listQuality())[0]).toMatchObject({ symbol: 'BTC', interval: '1h', status: 'ok', fresh: true });316    expect(await code(series.assertFresh('BTC', '1h', NOW + 6 * H))).toBe('stale');317  });318});319320describe('quality record and routes', () => {321  it('stores one row per (symbol, interval), replaced at each recomputation', async () => {322    const srcs = [fake('coinbase'), fake('bitstamp', { close: (ts) => (ts === LAST ? 101 : 100) })];323    const c = cfg({ symbols: ['BTC', 'ETH'] });324    await pipe.updateAll(srcs, c, () => NOW);325    const r = await pipe.recomputeQuality(c, ['coinbase', 'bitstamp'], NOW);326    expect(r['BTC:1h']).toMatchObject({ status: 'disagreement', bars: 300, missingHistory: 420 });327    let rows = await store.listQuality();328    expect(rows.map((q: any) => [q.symbol, q.interval, q.status, q.fresh, q.gaps])).toEqual([['BTC', '1h', 'disagreement', true, 0], ['ETH', '1h', 'disagreement', true, 0]]);329    expect(rows[0]!.last_ts!.getTime()).toBe(LAST);330    expect(rows[0]!.checked_at.getTime()).toBe(NOW);331    expect(rows[0]!.max_deviation_bps).toBeCloseTo((1 / 100.5) * 1e4, 6);332    expect(rows[0]!.detail.failures[0]!.code).toBe('disagreement');333    await pipe.recomputeQuality(c, ['coinbase', 'bitstamp'], NOW + 4 * H);334    rows = await store.listQuality();335    expect(rows).toHaveLength(2);336    expect(rows[0]).toMatchObject({ status: 'stale', fresh: false });337    await sql`truncate candles`;338    await pipe.recomputeQuality(c, ['coinbase', 'bitstamp'], NOW);339    expect((await store.listQuality())[0]).toMatchObject({ status: 'no_data', fresh: false, last_ts: null, max_deviation_bps: null });340  });341342  it('GET /market-data/candles and /market-data/status', async () => {343    const srcs = [fake('coinbase', { missing: new Set([LAST - H]) }), fake('bitstamp'), fake('kraken')];344    await pipe.updateAll(srcs, cfg(), () => NOW);345    await pipe.recomputeQuality(cfg(), ['coinbase', 'bitstamp', 'kraken'], NOW);346    const app = Fastify();347    routes.marketDataRoutes(app);348    const candles = (await app.inject({ method: 'GET', url: '/api/v1/market-data/candles?symbol=BTC&interval=1h&limit=3' })).json();349    expect(candles).toMatchObject({ symbol: 'BTC', interval: '1h', gaps: 0, priority: ['coinbase', 'bitstamp', 'kraken'], provided: { coinbase: 2, bitstamp: 1 } });350    expect(candles.bars.map((b: any) => [b.ts, b.source])).toEqual([[LAST - 2 * H, 'coinbase'], [LAST - H, 'bitstamp'], [LAST, 'coinbase']]);351    expect(Object.keys(candles.bars[0]).sort()).toEqual(['close', 'high', 'low', 'open', 'source', 'ts', 'volume']);352    expect((await app.inject({ method: 'GET', url: '/api/v1/market-data/candles?symbol=BTC&interval=1h' })).json().bars).toHaveLength(300);353    for (const bad of ['symbol=NOPE&interval=1h', 'symbol=BTC&interval=2h', 'symbol=BTC&interval=1h&limit=0', 'symbol=BTC&interval=1h&limit=9999999', 'interval=1h'])354      expect((await app.inject({ method: 'GET', url: `/api/v1/market-data/candles?${bad}` })).statusCode).not.toBe(200);355    const status = (await app.inject({ method: 'GET', url: '/api/v1/market-data/status' })).json();356    // V5: the base ten plus every symbol the token registry makes tradable on an execution-enabled network357    const BASE = ['BTC', 'ETH', 'SOL', 'LINK', 'AAVE', 'AVAX', 'BNB', 'DOGE', 'LTC', 'XRP'];358    expect(status.symbols.slice(0, 10)).toEqual(BASE);359    expect(status.symbols.length).toBeGreaterThan(10);360    expect(status.symbols).toEqual(expect.arrayContaining(['UNI', 'POL', 'HYPE']));361    expect(status.only['5m']).toEqual(BASE);362    expect(status.sources.map((s: any) => s.id)).toEqual(['coinbase', 'bitstamp', 'kraken']);363    for (const s of status.sources) { for (const b of BASE) expect(s.pairs[b]).toBeTruthy(); for (const k of Object.keys(s.pairs)) expect(status.symbols).toContain(k); }364    for (const sym of status.symbols) expect(status.sources.filter((s: any) => s.pairs[sym]).length).toBeGreaterThanOrEqual(2); // two exchanges at least365    expect(status.coverage).toEqual([366      { source: 'bitstamp', symbol: 'BTC', interval: '1h', bars: 300, first: new Date(LAST - 299 * H).toISOString(), last: new Date(LAST).toISOString() },367      { source: 'coinbase', symbol: 'BTC', interval: '1h', bars: 299, first: new Date(LAST - 299 * H).toISOString(), last: new Date(LAST).toISOString() },368      { source: 'kraken', symbol: 'BTC', interval: '1h', bars: 300, first: new Date(LAST - 299 * H).toISOString(), last: new Date(LAST).toISOString() },369    ]);370    expect(status.quality).toHaveLength(1);371    expect(status.quality[0]).toMatchObject({ symbol: 'BTC', interval: '1h', status: 'ok', fresh: true, gaps: 0, lastTs: new Date(LAST).toISOString(), provided: { coinbase: 299, bitstamp: 1 } });372    expect(status.thresholds).toMatchObject({ maxDeviationBps: 50, minSources: 2, maxLagBars: 1 });373    await app.close();374  });375376  it('both routes are behind the session check of the real server, and the old chart route is untouched', async () => {377    const { buildServer } = await import('../src/server.ts');378    const app = await buildServer();379    for (const url of ['/api/v1/market-data/status', '/api/v1/market-data/candles?symbol=BTC&interval=1h'])380      expect((await app.inject({ method: 'GET', url })).statusCode).toBe(401);381    expect(app.hasRoute({ method: 'GET', url: '/api/v1/market/:symbol' })).toBe(true);382    expect(app.hasRoute({ method: 'GET', url: '/api/v1/market-data/status' })).toBe(true);383    await app.close();384  });385});386
Files main
Menu

Theme