Repository Public
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