SPB Git

Repository Public

market-engine.test.ts 22.6 KB 339 lines typescript

1// Market data engine, pure parts (no network, no database): the three exchange adapters replayed against answers recorded2// live (scripts/record-market-fixtures.mts), bar validation, gap counting, cross-source deviation, the consolidation rule3// and the quality verdict behind assertFresh.4import { readFileSync } from 'node:fs';5import { beforeAll, describe, expect, it } from 'vitest';6import { consolidate, deviations, evaluate, spreadBps } from '../src/market/quality.ts';7import { INTERVAL_MS, SourceError, expectedLastTs, isClosed, type BySource, type Candle, type HttpJson, type Interval, type MarketSource, type SourceId } from '../src/market/types.ts';8import { findGaps, normalise, validateBars } from '../src/market/validate.ts';910type Fixture = { source: SourceId; interval: Interval; recordedAt: number; range: { start: number; end: number }; url: string; status: number; body: any };11const fx = (name: string): Fixture => JSON.parse(readFileSync(new URL(`./fixtures/market/${name}.json`, import.meta.url), 'utf8'));12const replay = (f: { url: string; status: number; body: unknown }): HttpJson => async (url) => { expect(url).toBe(f.url); return { status: f.status, body: structuredClone(f.body) }; };13const answer = (status: number, body: unknown): HttpJson => async () => ({ status, body });1415let make: Record<SourceId, (o: { get: HttpJson; spacingMs: number }) => MarketSource>;16beforeAll(async () => {17  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres:///unused' /* no query is made here: sources and validation only */, SPW_SIGNER_SECRET: 'k'.repeat(48) });18  const s = await import('../src/market/sources.ts');19  make = { coinbase: s.coinbaseSource, bitstamp: s.bitstampSource, kraken: s.krakenSource };20});2122const H = INTERVAL_MS['1h'];23const T0 = Date.UTC(2026, 5, 1, 0, 0), NOW = T0 + 100 * H + 60_000; // 100 closed hourly bars: T0 .. T0 + 99 h24const mk = (ts: number, close = 100, o: Partial<Candle> = {}): Candle => ({ ts, open: close, high: close * 1.01, low: close * 0.99, close, volume: 5, ...o });25const run = (n: number, close: (i: number) => number = () => 100, from = T0 + (100 - n) * H): Candle[] => Array.from({ length: n }, (_, i) => mk(from + i * H, close(i)));2627describe('adapters against recorded answers', () => {28  const SOURCES: SourceId[] = ['coinbase', 'bitstamp', 'kraken'];29  const PLAN: [Interval, number][] = [['1h', 48], ['1d', 30], ['5m', 24]];3031  for (const id of SOURCES) for (const [interval, n] of PLAN) {32    it(`${id} BTC ${interval}: closed bars only, oldest first, on the grid, all valid`, async () => {33      const f = fx(`${id}-BTC-${interval}`), iv = INTERVAL_MS[interval];34      const bars = await make[id]({ get: replay(f), spacingMs: 0 }).candles('BTC', interval, f.range, f.recordedAt);35      expect(bars).toHaveLength(n);36      expect(bars[0]!.ts).toBe(f.range.start);37      // the request reached one bar past the last closed one: the answer contains the open bar, the adapter drops it38      expect(bars.at(-1)!.ts).toBe(expectedLastTs(interval, f.recordedAt));39      expect(bars.at(-1)!.ts).toBe(f.range.end - iv);40      expect(JSON.stringify(f.body)).toContain(String(f.range.end / 1000));41      expect(bars.every((b, i) => i === 0 || b.ts === bars[i - 1]!.ts + iv)).toBe(true);42      expect(bars.every((b) => isClosed(b.ts, interval, f.recordedAt))).toBe(true);43      expect(Object.keys(bars[0]!).sort()).toEqual(['close', 'high', 'low', 'open', 'ts', 'volume']);44      expect(validateBars(bars, interval, f.recordedAt).rejected).toEqual([]);45      expect(bars.every((b) => b.close > 10_000 && b.close < 1_000_000 && b.volume >= 0)).toBe(true);46    });47  }4849  it('maps each exchange layout to the same fields (first recorded bar, by hand)', async () => {50    const c = fx('coinbase-BTC-1h'), b = fx('bitstamp-BTC-1h'), k = fx('kraken-BTC-1h');51    const cRow = (c.body as number[][]).find((r) => r[0] === c.range.start / 1000)!; // [time, low, high, open, close, volume]52    expect((await make.coinbase({ get: replay(c), spacingMs: 0 }).candles('BTC', '1h', c.range, c.recordedAt))[0])53      .toEqual({ ts: c.range.start, low: cRow[1], high: cRow[2], open: cRow[3], close: cRow[4], volume: cRow[5] });54    const bRow = b.body.data.ohlc.find((r: any) => Number(r.timestamp) === b.range.start / 1000);55    expect((await make.bitstamp({ get: replay(b), spacingMs: 0 }).candles('BTC', '1h', b.range, b.recordedAt))[0])56      .toEqual({ ts: b.range.start, open: Number(bRow.open), high: Number(bRow.high), low: Number(bRow.low), close: Number(bRow.close), volume: Number(bRow.volume) });57    const kRow = k.body.result.XXBTZUSD.find((r: any) => r[0] === k.range.start / 1000); // [time, open, high, low, close, vwap, volume, count]58    expect((await make.kraken({ get: replay(k), spacingMs: 0 }).candles('BTC', '1h', k.range, k.recordedAt))[0])59      .toEqual({ ts: k.range.start, open: Number(kRow[1]), high: Number(kRow[2]), low: Number(kRow[3]), close: Number(kRow[4]), volume: Number(kRow[6]) });60  });6162  it('the three exchanges, recorded at the same moment, agree within 50 bps on every hourly close', async () => {63    const by: BySource = {};64    for (const id of SOURCES) { const f = fx(`${id}-BTC-1h`); by[id] = await make[id]({ get: replay(f), spacingMs: 0 }).candles('BTC', '1h', f.range, f.recordedAt); }65    const d = deviations(by);66    expect(d).toHaveLength(48);67    expect(Math.max(...d.map((x) => x.bps))).toBeLessThan(50);68    expect(evaluate(by, '1h', fx('coinbase-BTC-1h').recordedAt, { lookback: 48 }).status).toBe('ok');69  });7071  it('kraken: a bar newer than `last` is never returned, even if the local clock says it is closed', async () => {72    const f = fx('kraken-BTC-1h');73    const bars = await make.kraken({ get: replay(f), spacingMs: 0 }).candles('BTC', '1h', f.range, f.recordedAt + 2 * H);74    expect(bars.at(-1)!.ts).toBe(f.body.result.last * 1000);75    expect(bars).toHaveLength(48);76  });7778  it('refuses an unknown pair as each exchange really answers it', async () => {79    for (const id of SOURCES) {80      const f = fx(`${id}-unknown-pair`);81      await expect(make[id]({ get: answer(f.status, f.body), spacingMs: 0 }).candles('BTC', '1h', { start: T0, end: T0 + H }, NOW)).rejects.toMatchObject({ source: id, kind: 'http' });82    }83  });8485  it('reports rate limiting, malformed answers, unlisted symbols and oversized ranges as typed errors', async () => {86    const range = { start: T0, end: T0 + H };87    await expect(make.coinbase({ get: answer(429, { message: 'Too Many Requests' }), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'rate_limited' });88    // observed live on 2026-11-10 after ten unpaced calls: HTTP 200 with this body89    await expect(make.kraken({ get: answer(200, { error: ['EGeneral:Too many requests'] }), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'rate_limited' });90    await expect(make.bitstamp({ get: answer(400, { status: 'error', code: '400.002', reason: 'Request rejected due to exceeded rate limit.' }), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'rate_limited' });91    await expect(make.coinbase({ get: answer(200, [[T0 / 1000, '1', 2, 1, 2, 3]]), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'schema' });92    await expect(make.coinbase({ get: answer(200, { message: 'maintenance' }), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'schema' });93    await expect(make.bitstamp({ get: answer(200, { data: { ohlc: [{ timestamp: String(T0 / 1000), open: 'abc', high: '2', low: '1', close: '2', volume: '1' }] } }), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'schema' });94    await expect(make.kraken({ get: answer(200, { error: [], result: { XXBTZUSD: [[T0 / 1000, '1', '2', '1', '2', '1', '1', 3]] } }), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toMatchObject({ kind: 'schema' });95    await expect(make.bitstamp({ get: answer(503, null), spacingMs: 0 }).candles('BTC', '1h', range, NOW)).rejects.toBeInstanceOf(SourceError);96    const never: HttpJson = async () => { throw new Error('must not be called'); };97    await expect(make.coinbase({ get: never, spacingMs: 0 }).candles('NOPE' as never, '1h', range, NOW)).rejects.toMatchObject({ kind: 'unsupported' });98    await expect(make.coinbase({ get: never, spacingMs: 0 }).candles('BTC', '1h', { start: T0, end: T0 + 298 * H }, NOW)).rejects.toMatchObject({ kind: 'unsupported' });99    await expect(make.coinbase({ get: never, spacingMs: 0 }).candles('BTC', '1h', { start: T0 + 1000, end: T0 + H }, NOW)).rejects.toMatchObject({ kind: 'unsupported' });100  });101102  it('builds the documented requests', async () => {103    const urls: string[] = [];104    const get: HttpJson = async (url) => { urls.push(url); return { status: 200, body: url.includes('kraken') ? { error: [], result: { XDGUSD: [], last: 0 } } : url.includes('bitstamp') ? { data: { ohlc: [] } } : [] }; };105    const past = { start: T0, end: T0 + 297 * H };106    await make.coinbase({ get, spacingMs: 0 }).candles('ETH', '1h', past, NOW + 1000 * H);107    await make.coinbase({ get, spacingMs: 0 }).candles('ETH', '1h', { start: T0, end: T0 + 99 * H }, NOW);108    await make.bitstamp({ get, spacingMs: 0 }).candles('ETH', '1d', { start: Date.UTC(2026, 0, 1), end: Date.UTC(2026, 0, 10) }, NOW);109    await make.kraken({ get, spacingMs: 0 }).candles('DOGE', '5m', { start: T0, end: T0 + H }, NOW);110    expect(urls).toEqual([111      'https://api.exchange.coinbase.com/products/ETH-USD/candles?granularity=3600&start=2026-06-01T00:00:00.000Z&end=2026-06-13T09:00:00.000Z',112      // a window touching the present ends at the current second, so that the CDN cache cannot serve an older copy113      'https://api.exchange.coinbase.com/products/ETH-USD/candles?granularity=3600&start=2026-06-01T00:00:00.000Z&end=2026-06-05T04:01:00.000Z',114      'https://www.bitstamp.net/api/v2/ohlc/ethusd/?step=86400&limit=10&end=1768003200',115      `https://api.kraken.com/0/public/OHLC?pair=XDGUSD&interval=5&since=${T0 / 1000 - 1}`,116    ]);117  });118119  it('paces the calls to one exchange', async () => {120    const at: number[] = [];121    const src = make.coinbase({ get: async () => { at.push(Date.now()); return { status: 200, body: [] }; }, spacingMs: 60 });122    await Promise.all([0, 1, 2].map(() => src.candles('BTC', '1h', { start: T0, end: T0 + H }, NOW)));123    expect(at[1]! - at[0]!).toBeGreaterThanOrEqual(55);124    expect(at[2]! - at[1]!).toBeGreaterThanOrEqual(55);125  });126});127128describe('validation', () => {129  const ts = T0 + 10 * H, good = mk(ts);130  const cases: [string, Candle, string][] = [131    ['zero open', { ...good, open: 0 }, 'non_positive_price'],132    ['negative low', { ...good, low: -1 }, 'non_positive_price'],133    ['zero close', { ...good, close: 0, low: 0 }, 'non_positive_price'],134    ['negative volume', { ...good, volume: -0.1 }, 'negative_volume'],135    ['high below low', { ...good, high: 98, low: 99, open: 98.5, close: 98.5 }, 'high_below_low'],136    ['open above high', { ...good, open: 102 }, 'open_out_of_range'],137    ['open below low', { ...good, open: 98 }, 'open_out_of_range'],138    ['close above high', { ...good, close: 101.5 }, 'close_out_of_range'],139    ['close below low', { ...good, close: 98.9 }, 'close_out_of_range'],140    ['timestamp off the grid', { ...good, ts: ts + 60_000 }, 'off_grid'],141    ['fractional timestamp', { ...good, ts: ts + 0.5 }, 'off_grid'],142    ['bar from the future', { ...good, ts: T0 + 200 * H }, 'future'],143    ['bar still open', { ...good, ts: T0 + 100 * H }, 'not_closed'],144    ['NaN price', { ...good, high: NaN }, 'not_finite'],145    ['infinite volume', { ...good, volume: Infinity }, 'not_finite'],146  ];147  for (const [name, bar, reason] of cases) it(`rejects: ${name}`, () => {148    const r = validateBars([mk(ts - H), bar, mk(ts + H)], '1h', NOW);149    expect(r.rejected).toEqual([{ ts: bar.ts, reason }]);150    expect(r.bars.map((b) => b.ts)).toEqual([ts - H, ts + H]);151  });152153  it('accepts a flat bar without volume and a bar that closed just long enough ago', () => {154    expect(validateBars([{ ts, open: 5, high: 5, low: 5, close: 5, volume: 0 }], '1h', NOW).rejected).toEqual([]);155    const last = T0 + 99 * H;156    expect(validateBars([mk(last)], '1h', last + H + 9_999).rejected).toEqual([{ ts: last, reason: 'not_closed' }]);157    expect(validateBars([mk(last)], '1h', last + H + 10_000).rejected).toEqual([]);158  });159160  it('rejects every copy of a duplicated timestamp', () => {161    const r = validateBars([mk(ts, 100), mk(ts + H, 100), mk(ts, 101)], '1h', NOW);162    expect(r.rejected).toEqual([{ ts, reason: 'duplicate' }, { ts, reason: 'duplicate' }]);163    expect(r.bars.map((b) => b.ts)).toEqual([ts + H]);164  });165166  it('normalises: oldest first, the six fields only', () => {167    const out = normalise([{ ...mk(ts + H), extra: 1 } as Candle, mk(ts)]);168    expect(out.map((b) => b.ts)).toEqual([ts, ts + H]);169    expect(Object.keys(out[1]!)).toEqual(['ts', 'open', 'high', 'low', 'close', 'volume']);170  });171172  it('daily bars must open at 00:00 UTC', () => {173    const day = Date.UTC(2026, 5, 1);174    expect(validateBars([mk(day)], '1d', NOW).rejected).toEqual([]);175    expect(validateBars([mk(day + 4 * H)], '1d', NOW).rejected).toEqual([{ ts: day + 4 * H, reason: 'off_grid' }]);176  });177});178179describe('gaps', () => {180  it('counts every missing slot and reports the holes', () => {181    expect(findGaps([T0, T0 + H, T0 + 2 * H], '1h')).toEqual({ gaps: 0, ranges: [] });182    expect(findGaps([T0, T0 + 2 * H, T0 + 3 * H, T0 + 7 * H], '1h')).toEqual({ gaps: 4, ranges: [{ from: T0 + H, to: T0 + H, missing: 1 }, { from: T0 + 4 * H, to: T0 + 6 * H, missing: 3 }] });183    expect(findGaps([], '1h').gaps).toBe(0);184    expect(findGaps([Date.UTC(2026, 0, 1), Date.UTC(2026, 0, 4)], '1d').gaps).toBe(2);185  });186});187188describe('cross-source deviation', () => {189  it('is the spread of the closes in basis points of their midpoint', () => {190    expect(spreadBps([100])).toBeNull();191    expect(spreadBps([100, 100])).toBe(0);192    expect(spreadBps([99.75, 100.25])).toBeCloseTo(50, 9);193    expect(spreadBps([100, 100.1, 101])).toBeCloseTo((1 / 100.5) * 1e4, 9);194  });195  it('is computed per bar, only where at least two sources have the bar', () => {196    const d = deviations({ coinbase: [mk(T0, 100), mk(T0 + H, 100), mk(T0 + 2 * H, 100)], bitstamp: [mk(T0, 100.5), mk(T0 + 2 * H, 100)], kraken: [mk(T0, 99.5)] });197    expect(d.map((x) => x.ts)).toEqual([T0, T0 + 2 * H]);198    expect(d[0]!.bps).toBeCloseTo(100, 9);199    expect(d[0]!.closes).toEqual({ coinbase: 100, bitstamp: 100.5, kraken: 99.5 });200    expect(d[1]!.bps).toBe(0);201  });202});203204describe('consolidation rule', () => {205  const by: BySource = {206    coinbase: [mk(T0, 100, { volume: 1 }), mk(T0 + 2 * H, 102, { volume: 1 })],207    bitstamp: [mk(T0, 100.2, { volume: 2 }), mk(T0 + H, 101.2, { volume: 2 }), mk(T0 + 2 * H, 102.2, { volume: 2 })],208    kraken: [mk(T0 + H, 101.4, { volume: 3 }), mk(T0 + 4 * H, 104.4, { volume: 3 })],209  };210  it('takes the whole bar of the highest-ranked source that has it, and says which', () => {211    const s = consolidate(by);212    expect(s.map((b) => [b.ts, b.source])).toEqual([[T0, 'coinbase'], [T0 + H, 'bitstamp'], [T0 + 2 * H, 'coinbase'], [T0 + 4 * H, 'kraken']]);213    // no field of another source leaks into a bar214    for (const b of s) { const { source, ...rest } = b; expect(by[source]!.find((c) => c.ts === b.ts)).toEqual(rest); }215    expect(s[1]).toEqual({ ...mk(T0 + H, 101.2, { volume: 2 }), source: 'bitstamp' });216  });217  it('does not invent a bar no source has, and follows the given priority', () => {218    expect(consolidate(by).some((b) => b.ts === T0 + 3 * H)).toBe(false);219    expect(consolidate(by, ['kraken', 'bitstamp']).map((b) => b.source)).toEqual(['bitstamp', 'kraken', 'bitstamp', 'kraken']);220    expect(consolidate({})).toEqual([]);221  });222});223224describe('quality verdict (the logic of assertFresh)', () => {225  const three = (): BySource => ({ coinbase: run(100), bitstamp: run(100, () => 100.1), kraken: run(100, () => 99.95) });226  const O = { lookback: 100 };227228  it('ok: fresh, complete, sources in agreement', () => {229    const r = evaluate(three(), '1h', NOW, O);230    expect(r).toMatchObject({ status: 'ok', failures: [], fresh: true, lagBars: 0, gaps: 0, bars: 100, missingHistory: 0, lastTs: T0 + 99 * H, expectedTs: T0 + 99 * H, provided: { coinbase: 100 } });231    expect(r.maxDeviationBps).toBeCloseTo(spreadBps([99.95, 100])!, 9); // the bar used (coinbase) against its nearest confirming source232    expect(r.agreement).toHaveLength(3);233    expect(r.window.compared).toBe(100);234  });235236  it('stale: one bar of delay is tolerated, two are not', () => {237    expect(evaluate(three(), '1h', NOW + H, O)).toMatchObject({ status: 'ok', lagBars: 1 });238    const r = evaluate(three(), '1h', NOW + 2 * H, O);239    expect(r).toMatchObject({ status: 'stale', fresh: false, lagBars: 2 });240    expect(evaluate(three(), '1h', NOW, { ...O, maxLagBars: 0 }).status).toBe('ok');241    expect(evaluate(three(), '1h', NOW + H, { ...O, maxLagBars: 0 }).status).toBe('stale');242    expect(evaluate(three(), '1h', NOW + 30 * 24 * H, O).status).toBe('stale');243  });244245  it('a bar that is not closed yet, or dated in the future, never counts as the latest bar', () => {246    const by = three();247    by.coinbase!.push(mk(T0 + 100 * H), mk(T0 + 500 * H));248    expect(evaluate(by, '1h', NOW, O)).toMatchObject({ status: 'ok', lastTs: T0 + 99 * H });249  });250251  it('gap: a bar missing on every source inside the window', () => {252    const hole = T0 + 50 * H, by = three();253    for (const s of Object.keys(by) as SourceId[]) by[s] = by[s]!.filter((c) => c.ts !== hole);254    const r = evaluate(by, '1h', NOW, O);255    expect(r).toMatchObject({ status: 'gap', gaps: 1, gapRanges: [{ from: hole, to: hole, missing: 1 }] });256    // outside the requested window the hole does not matter257    expect(evaluate(by, '1h', NOW, { lookback: 40 }).status).toBe('ok');258  });259260  it('a bar missing on the primary source only is filled by the next source, visibly', () => {261    const hole = T0 + 50 * H, by = three();262    by.coinbase = by.coinbase!.filter((c) => c.ts !== hole);263    const r = evaluate(by, '1h', NOW, O);264    expect(r).toMatchObject({ status: 'ok', gaps: 0, provided: { coinbase: 99, bitstamp: 1 } });265    expect(r.sources.coinbase).toMatchObject({ gaps: 1, bars: 99 });266  });267268  it('insufficient history: fewer bars than the lookback (unless the caller only wants a status)', () => {269    expect(evaluate(three(), '1h', NOW, { lookback: 150 })).toMatchObject({ status: 'insufficient_history', missingHistory: 50, gaps: 0 });270    expect(evaluate(three(), '1h', NOW, { lookback: 150, requireHistory: false }).status).toBe('ok');271  });272273  it('disagreement: closes further apart than the threshold on any of the last bars', () => {274    const by = three();275    // the bar the series uses (coinbase, first in priority) is the one that is off: no other source confirms it276    by.coinbase = run(100, (i) => (i === 98 ? 100.9 : 100));277    const r = evaluate(by, '1h', NOW, O);278    expect(r.status).toBe('disagreement');279    expect(r.maxDeviationBps).toBeGreaterThan(50);280    expect(evaluate(by, '1h', NOW, { ...O, maxDeviationBps: 100 }).status).toBe('ok');281    // a third source that is off while the bar used is confirmed by another one does not block282    const outlier = three(); outlier.bitstamp = run(100, (i) => (i === 98 ? 100.9 : 100.1));283    expect(evaluate(outlier, '1h', NOW, O).status).toBe('ok');284    // …but with only two sources there is nothing else to confirm the bar285    expect(evaluate({ coinbase: outlier.coinbase, bitstamp: outlier.bitstamp }, '1h', NOW, O).status).toBe('disagreement');286    by.coinbase = run(100);287    // an old disagreement (outside the last bars) is reported in the window statistics but does not block288    by.bitstamp = run(100, (i) => (i === 50 ? 103 : 100.1));289    const old = evaluate(by, '1h', NOW, O);290    expect(old.status).toBe('ok');291    expect(old.window.max).toBeGreaterThan(250);292    // exactly at the threshold passes, just above fails293    expect(evaluate({ coinbase: run(100, () => 99.75), bitstamp: run(100, () => 100.25) }, '1h', NOW, O).status).toBe('ok');294    expect(evaluate({ coinbase: run(100, () => 99.75), bitstamp: run(100, () => 100.26) }, '1h', NOW, O).status).toBe('disagreement');295  });296297  it('single source down: with two sources, losing one fails; the series is never trusted on one source alone', () => {298    const two = { priority: ['coinbase', 'bitstamp'] as SourceId[], ...O };299    expect(evaluate({ coinbase: run(100), bitstamp: run(100) }, '1h', NOW, two).status).toBe('ok');300    // bitstamp stopped two hours ago301    expect(evaluate({ coinbase: run(100), bitstamp: run(98, () => 100, T0) }, '1h', NOW, two)).toMatchObject({ status: 'insufficient_sources', maxDeviationBps: 0 });302    // the primary stopped: the series falls back to bitstamp, which nobody confirms303    expect(evaluate({ coinbase: run(98, () => 100, T0), bitstamp: run(100) }, '1h', NOW, two)).toMatchObject({ status: 'insufficient_sources', provided: { coinbase: 98, bitstamp: 2 } });304    expect(evaluate({ coinbase: run(100) }, '1h', NOW, two)).toMatchObject({ status: 'insufficient_sources', maxDeviationBps: null });305    // a third source that is not in the configured list cannot vouch306    expect(evaluate({ coinbase: run(100), kraken: run(100) }, '1h', NOW, two).status).toBe('insufficient_sources');307  });308309  it('with three sources, one down is tolerated as long as the two others agree; two down is not', () => {310    const by = three();311    by.coinbase = run(90, () => 100, T0);312    expect(evaluate(by, '1h', NOW, O)).toMatchObject({ status: 'ok', provided: { coinbase: 90, bitstamp: 10 } });313    by.kraken = run(97, () => 99.95, T0);314    expect(evaluate(by, '1h', NOW, O).status).toBe('insufficient_sources');315    expect(evaluate(three(), '1h', NOW, { ...O, minSources: 3 }).status).toBe('ok');316    by.kraken = run(100, () => 99.95);317    expect(evaluate(by, '1h', NOW, { ...O, minSources: 3 }).status).toBe('insufficient_sources');318  });319320  it('no data', () => {321    expect(evaluate({}, '1h', NOW)).toMatchObject({ status: 'no_data', fresh: false, lastTs: null });322    expect(evaluate({ coinbase: [mk(T0 + 100 * H)] }, '1h', NOW).status).toBe('no_data');323  });324325  it('several failures are all listed; the status is the first', () => {326    const r = evaluate({ coinbase: run(50, () => 100, T0).filter((_, i) => i !== 20) }, '1h', NOW, O);327    expect(r.failures.map((f) => f.code)).toEqual(['stale', 'gap', 'insufficient_history', 'insufficient_sources']);328    expect(r.status).toBe('stale');329  });330});331332describe('market configuration defaults', () => {333  it('default depths are days, not milliseconds: two years of 1h, 30 days of 5m, all of 1d', async () => {334    const { marketConfig } = await import('../src/market/pipeline.ts');335    for (const k of ['SPW_MARKET_DEPTH_1H_DAYS', 'SPW_MARKET_DEPTH_1D_DAYS', 'SPW_MARKET_DEPTH_5M_DAYS']) delete process.env[k];336    expect(marketConfig().depthMs).toEqual({ '1h': 730 * 86_400_000, '1d': null, '5m': 30 * 86_400_000 });337  });338});339
Files main
Menu

Theme