Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added because
Empty file.
Empty file added decisions
Empty file.
Empty file added it
Empty file.
169 changes: 169 additions & 0 deletions src/__tests__/spreads.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
162 changes: 162 additions & 0 deletions src/routes/spreads.ts
Original file line number Diff line number Diff line change
@@ -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}` })
}
})
}
Empty file added that
Empty file.
Empty file added to
Empty file.
Empty file added Status
Empty file.