diff --git a/because b/because new file mode 100644 index 0000000..e69de29 diff --git a/decisions b/decisions new file mode 100644 index 0000000..e69de29 diff --git a/it b/it new file mode 100644 index 0000000..e69de29 diff --git a/src/__tests__/spreads.test.ts b/src/__tests__/spreads.test.ts new file mode 100644 index 0000000..f489b02 --- /dev/null +++ b/src/__tests__/spreads.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../db', () => ({ pgPool: { query: mockQuery } })) +vi.mock('../config', () => ({ activeNetwork: 'testnet' })) + +import { registerSpreadsRoutes } from '../routes/spreads' + +async function buildApp() { + const app = Fastify({ logger: false }) + await registerSpreadsRoutes(app) + await app.ready() + return app +} + +/** One grouped row as the query returns it. */ +function row(source: string, pairKey: string, bid: string, ask: string, observations = 10) { + return { + source, + pair_key: pairKey, + bid, + ask, + observations, + last_seen: new Date('2026-09-01T12:00:00.000Z'), + } +} + +describe('GET /spreads/:asset', () => { + beforeEach(() => { + mockQuery.mockReset() + }) + + it('computes basis points against the mid, not the ask', async () => { + // bid 99, ask 101 → mid 100, range 2 → exactly 200 bps. + // Against the ask it would be 198.02 bps, which is the common mistake: + // it makes the figure depend on which side you divided by, so two venues + // quoting the same absolute range would score differently. + mockQuery.mockResolvedValue({ rows: [row('sdex', 'XLM/USDC', '99', '101')] }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/XLM' })).json() + + expect(body.venues).toHaveLength(1) + expect(body.venues[0].spreadBps).toBeCloseTo(200, 6) + expect(body.venues[0].mid).toBe(100) + }) + + it('groups by venue AND pair, because two pairs on one venue are different books', async () => { + mockQuery.mockResolvedValue({ + rows: [row('sdex', 'XLM/USDC', '0.99', '1.01'), row('sdex', 'XLM/EURC', '0.90', '0.92')], + }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/XLM' })).json() + + // Collapsing these to one "sdex spread" would take min 0.90 / max 1.01 + // across two unrelated price scales and call the gap a spread. + expect(body.venues).toHaveLength(2) + expect(body.venues.map((v: any) => v.pairKey).sort()).toEqual(['XLM/EURC', 'XLM/USDC']) + }) + + it('reports the tightest venue first and as `tightest`', async () => { + mockQuery.mockResolvedValue({ + rows: [ + row('wide', 'XLM/USDC', '90', '110'), // 2000 bps + row('tight', 'XLM/USDC', '99.9', '100.1'), // 20 bps + ], + }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/XLM' })).json() + + expect(body.venues[0].venue).toBe('tight') + expect(body.tightest.venue).toBe('tight') + expect(body.tightest.spreadBps).toBeLessThan(body.venues[1].spreadBps) + }) + + it('ignores a venue with a single observation, which would fake a 0 bps spread', async () => { + // A lone print has a zero range by construction, so it would win "tightest" + // against every genuinely liquid venue. One observation is not evidence. + mockQuery.mockResolvedValue({ + rows: [ + row('lonely', 'XLM/USDC', '100', '100', 1), + row('real', 'XLM/USDC', '99', '101', 40), + ], + }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/XLM' })).json() + + expect(body.venues.map((v: any) => v.venue)).toEqual(['real']) + expect(body.tightest.venue).toBe('real') + }) + + it('returns tightest: null when nothing quoted the asset', async () => { + // Distinct from a zero spread — an empty object would read as "free to trade". + mockQuery.mockResolvedValue({ rows: [] }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/NOPE' })).json() + + expect(body.venues).toEqual([]) + expect(body.tightest).toBeNull() + }) + + it('scopes the query to one network, so two chains are never pooled', async () => { + mockQuery.mockResolvedValue({ rows: [] }) + const app = await buildApp() + + await app.inject({ method: 'GET', url: '/spreads/XLM?network=mainnet' }) + + const [, params] = mockQuery.mock.calls[0] + expect(params[1]).toBe('mainnet') + expect(mockQuery.mock.calls[0][0]).toMatch(/network = \$2/) + }) + + it('defaults to the active network rather than querying across both', async () => { + mockQuery.mockResolvedValue({ rows: [] }) + const app = await buildApp() + + const body = (await app.inject({ method: 'GET', url: '/spreads/XLM' })).json() + + expect(body.network).toBe('testnet') + expect(mockQuery.mock.calls[0][1][1]).toBe('testnet') + }) + + it('rejects an unknown network instead of silently falling back', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/spreads/XLM?network=futurenet' }) + + expect(res.statusCode).toBe(400) + expect(mockQuery).not.toHaveBeenCalled() + }) + + it('rejects an unsupported window and names the valid ones', async () => { + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/spreads/XLM?window=7y' }) + + expect(res.statusCode).toBe(400) + expect(res.json().error).toMatch(/5m/) + expect(mockQuery).not.toHaveBeenCalled() + }) + + it('narrows the lookback window as requested', async () => { + mockQuery.mockResolvedValue({ rows: [] }) + const app = await buildApp() + + await app.inject({ method: 'GET', url: '/spreads/XLM?window=5m' }) + const fiveMin = mockQuery.mock.calls[0][1][2] as Date + mockQuery.mockClear() + + await app.inject({ method: 'GET', url: '/spreads/XLM?window=1h' }) + const oneHour = mockQuery.mock.calls[0][1][2] as Date + + expect(oneHour.getTime()).toBeLessThan(fiveMin.getTime()) + }) + + it('answers 500 with a message rather than throwing when the query fails', async () => { + mockQuery.mockRejectedValue(new Error('connection refused')) + const app = await buildApp() + + const res = await app.inject({ method: 'GET', url: '/spreads/XLM' }) + + expect(res.statusCode).toBe(500) + expect(res.json().error).toMatch(/connection refused/) + }) +}) diff --git a/src/index.ts b/src/index.ts index 99a0f53..90a8392 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ import { registerUsageRoutes } from './api/usage' import { registerFacilitatorRoutes } from './api/facilitator' import { registerPriceRoutes } from './routes/price' import { registerVolumeRoutes } from './routes/volumes' +import { registerSpreadsRoutes } from './routes/spreads' import { registerBenchmarkRoutes } from './routes/benchmark' import { registerOracleRoutes } from './routes/oracle' import { registerBasketRoutes } from './routes/basket' @@ -137,6 +138,7 @@ async function main() { await registerHistoryRoutes(app) await registerPriceRoutes(app) await registerVolumeRoutes(app) + await registerSpreadsRoutes(app) await registerBenchmarkRoutes(app) await registerOracleRoutes(app) await registerBasketRoutes(app) diff --git a/src/routes/spreads.ts b/src/routes/spreads.ts new file mode 100644 index 0000000..8fdb408 --- /dev/null +++ b/src/routes/spreads.ts @@ -0,0 +1,162 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { activeNetwork, type NetworkName } from '../config' + +// Supported windows → lookback in minutes. A spread is a statement about *now*, +// so these are deliberately short: widen the window far enough and you stop +// measuring liquidity and start measuring the day's price drift. +const WINDOW_MINUTES = { + '5m': 5, + '15m': 15, + '1h': 60, + '24h': 60 * 24, +} as const + +type SpreadWindow = keyof typeof WINDOW_MINUTES + +const WINDOWS = Object.keys(WINDOW_MINUTES) as SpreadWindow[] + +/** A venue needs at least this many observations before its spread means anything. */ +const MIN_OBSERVATIONS = 2 + +interface VenueSpread { + venue: string + pairKey: string + bid: number + ask: number + mid: number + spreadBps: number + observations: number + lastSeen: string +} + +/** + * Register the bid/ask spread endpoint. + * + * `GET /spreads/:asset?window=5m|15m|1h|24h&network=testnet|mainnet` + * + * Reports, for every (venue, pair) that quoted `asset` inside the window, the + * low, the high and the gap between them in basis points — then the tightest of + * them, which is the practical answer to "where should I trade this". + * + * ## What the numbers actually mean + * + * Lens stores one `price` per observation, not a quoted bid and ask. So a + * venue's spread here is the dispersion of its own recent prints — the range it + * traded through over the window — rather than a book's top-of-book gap. That + * is a real liquidity signal (a thin venue prints a wide range, a deep one + * prints a tight one) but it is **not** the number that venue's own order book + * would report, and it should not be presented as one. + * + * Basis points are taken against the **mid**, not against the ask: + * + * spreadBps = (ask - bid) / ((ask + bid) / 2) * 10_000 + * + * Dividing by the ask makes the figure depend on which side you divided by, so + * two venues quoting the same absolute range score differently. The mid is + * symmetric. + * + * ## Why the grouping is (venue, pair) and not venue + * + * XLM/USDC and XLM/EURC on the same venue are different books at different + * price scales. Collapsing them into one "venue spread" would take the min and + * max across two unrelated scales and report the gap between them — a number + * that is not merely imprecise but meaningless. + */ +export async function registerSpreadsRoutes(app: FastifyInstance) { + app.get<{ + Params: { asset: string } + Querystring: { window?: string; network?: string } + }>('/spreads/:asset', async (req, reply) => { + const { asset } = req.params + const window = req.query.window ?? '5m' + + if (!asset) { + return reply.status(400).send({ error: 'Asset parameter is required' }) + } + + if (!(window in WINDOW_MINUTES)) { + return reply + .status(400) + .send({ error: `window must be one of: ${WINDOWS.join(', ')}` }) + } + + // Prices from two chains are not comparable, and a spread computed across + // both is not a wide spread — it is a meaningless one. Default to this + // process's own network rather than silently pooling them. + const requested = req.query.network + if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { + return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + } + const network: NetworkName = (requested as NetworkName) ?? activeNetwork + + const minutes = WINDOW_MINUTES[window as SpreadWindow] + const endTime = new Date() + const startTime = new Date(endTime.getTime() - minutes * 60 * 1000) + + try { + const { rows } = await pgPool.query( + `SELECT source, + pair_key, + MIN(price)::numeric AS bid, + MAX(price)::numeric AS ask, + COUNT(*)::int AS observations, + MAX(timestamp) AS last_seen + FROM price_points + WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 + AND timestamp >= $3 + GROUP BY source, pair_key`, + [asset, network, startTime], + ) + + const venues: VenueSpread[] = [] + + for (const row of rows) { + const bid = parseFloat(row.bid) + const ask = parseFloat(row.ask) + const observations = Number(row.observations) || 0 + + // A single print has a zero range by construction, which would report a + // perfect 0 bps spread and beat every real venue for "tightest". One + // observation is not evidence of liquidity. + if (observations < MIN_OBSERVATIONS) continue + if (!Number.isFinite(bid) || !Number.isFinite(ask)) continue + + const mid = (ask + bid) / 2 + if (mid <= 0) continue + + venues.push({ + venue: row.source, + pairKey: row.pair_key, + bid, + ask, + mid, + spreadBps: ((ask - bid) / mid) * 10_000, + observations, + lastSeen: new Date(row.last_seen).toISOString(), + }) + } + + venues.sort((a, b) => a.spreadBps - b.spreadBps) + + return { + asset, + network, + window, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + // Per venue-and-pair, tightest first. + venues, + // The tightest quote available anywhere in the window, or null when no + // venue quoted this asset. Null rather than a zeroed object: "nobody + // quoted it" and "the spread is zero" must not look alike. + tightest: venues[0] ?? null, + } + } catch (err) { + return reply + .status(500) + .send({ error: `Spread aggregation failed: ${(err as Error).message}` }) + } + }) +} diff --git a/that b/that new file mode 100644 index 0000000..e69de29 diff --git a/to b/to new file mode 100644 index 0000000..e69de29 diff --git "a/\357\200\252\357\200\252Status\357\200\272" "b/\357\200\252\357\200\252Status\357\200\272" new file mode 100644 index 0000000..e69de29