diff --git a/.changeset/graphql-extended-queries.md b/.changeset/graphql-extended-queries.md new file mode 100644 index 00000000..1c8f7881 --- /dev/null +++ b/.changeset/graphql-extended-queries.md @@ -0,0 +1,9 @@ +--- +"lens": minor +--- + +Add GraphQL query extensions for market screener, volumes, bid/ask spreads, oracle price comparisons, and historical range filtering: +- Add `from` and `to` ISO 8601 range arguments and boundary validation to `getPriceHistory` (closes #370). +- Add `oracle(asset: String!, network: String)` query with typed source status distinguishing available, unreachable, empty, and not configured sources (closes #369). +- Add `volumes(asset: String!, window: String, network: String)` and `spreads(asset: String!, window: String, network: String)` queries with typed `VenueVolume` and `VenueQuote` lists shared with REST routes (closes #368). +- Add `screener(filter, sort, pagination, network)` query supporting filters, sorting, cursor pagination, and explicit `market_cap` rejection matching REST (closes #367). diff --git a/schema.graphql b/schema.graphql index 68955141..cba92f68 100644 --- a/schema.graphql +++ b/schema.graphql @@ -62,6 +62,7 @@ enum OracleSourceStatus { type OracleSource { name: String! price: Float + value: Float timestamp: String deviationPct: Float @@ -71,6 +72,7 @@ type OracleSource { """ status: OracleSourceStatus! ageSeconds: Int + age: Int } type OracleComparison { @@ -82,6 +84,83 @@ type OracleComparison { status: String! fetchedAt: String! oracles: [OracleSource!]! + sources: [OracleSource!]! +} + +type VenueVolume { + venue: String! + volume: Float! + tradeCount: Int! +} + +type VolumeReport { + asset: String! + network: String! + window: String! + startTime: String! + endTime: String! + totalVolume: Float! + tradeCount: Int! + venues: [VenueVolume!]! +} + +type VenueQuote { + venue: String! + pairKey: String! + bid: Float! + ask: Float! + mid: Float! + spreadBps: Float! + spreadPct: Float! + observations: Int! + lastSeen: String! +} + +type SpreadReport { + asset: String! + network: String! + window: String! + startTime: String! + endTime: String! + venues: [VenueQuote!]! + tightest: VenueQuote +} + +input ScreenerFilterInput { + volume: Float + change24hMin: Float + change24hMax: Float + priceMin: Float + priceMax: Float + liquidity: Float + marketCap: Float +} + +input ScreenerSortInput { + field: String + order: String +} + +input ScreenerPaginationInput { + cursor: String + limit: Int +} + +type ScreenerItem { + pairKey: String! + pair_key: String! + volume: Float! + price: Float! + change24h: Float! + change_24h: Float! + liquidity: Float! +} + +type ScreenerResult { + data: [ScreenerItem!]! + nextCursor: String + hasMore: Boolean! + total: Int } type Query { @@ -116,6 +195,19 @@ type Query { The network argument defaults to the active network. """ oracle(asset: String!, network: String): OracleComparison + + """Volume report for an asset across all trading venues.""" + volumes(asset: String!, window: String, network: String): VolumeReport + + """ + Bid/ask spread and price dispersion report for an asset across venues and pairs. + """ + spreads(asset: String!, window: String, network: String): SpreadReport + + """ + Market screener filtering, sorting, and cursor pagination across tracked pairs. + """ + screener(filter: ScreenerFilterInput, sort: ScreenerSortInput, pagination: ScreenerPaginationInput, network: String): ScreenerResult } type Subscription { diff --git a/src/__tests__/graphqlExtendedQueries.test.ts b/src/__tests__/graphqlExtendedQueries.test.ts new file mode 100644 index 00000000..00032421 --- /dev/null +++ b/src/__tests__/graphqlExtendedQueries.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import Fastify, { type FastifyInstance } from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../db', () => ({ + prisma: {}, + pgPool: { query: mockQuery }, +})) +vi.mock('../redis', () => ({ getCachedPrice: vi.fn() })) +vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() })) +vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() })) +vi.mock('../config', () => ({ + config: { pairs: [] }, + activeNetwork: 'testnet', +})) + +const mockReflectorAdapter = vi.hoisted(() => ({ + name: 'reflector', + isConfigured: vi.fn((net: string) => true), + getPrice: vi.fn(), +})) + +vi.mock('../ingest/oracles/reflector', () => ({ + reflectorOracleAdapter: mockReflectorAdapter, +})) + +import { registerGraphQL } from '../api/graphql' +import { registerVolumeRoutes } from '../routes/volumes' +import { registerSpreadsRoutes } from '../routes/spreads' +import { registerScreenerRoutes, MARKET_CAP_UNSUPPORTED } from '../routes/screener' + +async function postGql(app: FastifyInstance, query: string, variables?: Record) { + const res = await app.inject({ + method: 'POST', + url: '/graphql', + payload: { query, variables }, + }) + return res.json() +} + +describe('GraphQL Extended Query Suite (#367, #368, #369, #370)', () => { + let app: FastifyInstance + + beforeEach(async () => { + vi.clearAllMocks() + mockQuery.mockResolvedValue({ rows: [] }) + app = Fastify({ logger: false }) + await registerGraphQL(app) + await registerVolumeRoutes(app) + await registerSpreadsRoutes(app) + await registerScreenerRoutes(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + }) + + describe('Issue #370: getPriceHistory from / to timestamp support', () => { + it('supports bounded from and to parameters', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + bucket: new Date('2026-10-15T12:00:00.000Z'), + window: '1h', + vwap: 0.12, + sdex_vwap: 0.12, + amm_vwap: 0.12, + volume: 1500, + trade_count: 10, + open_price: 0.11, + close_price: 0.12, + high_price: 0.13, + low_price: 0.10, + }, + ], + }) + + const query = ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1h" + from: "2026-10-15T00:00:00.000Z" + to: "2026-10-15T23:59:59.000Z" + limit: 50 + ) { + bucket + window + vwap + volume + tradeCount + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.getPriceHistory).toHaveLength(1) + expect(res.data.getPriceHistory[0].vwap).toBe(0.12) + expect(res.data.getPriceHistory[0].volume).toBe(1500) + + const lastCall = mockQuery.mock.calls[0] + const sql = lastCall[0] as string + const params = lastCall[1] as any[] + expect(sql).toContain('bucket >= $3') + expect(sql).toContain('bucket <= $4') + expect(params[0]).toBe('USDC/XLM') + expect(params[1]).toBe('1h') + expect(params[2]).toEqual(new Date('2026-10-15T00:00:00.000Z')) + expect(params[3]).toEqual(new Date('2026-10-15T23:59:59.000Z')) + }) + + it('rejects when from is after to with INVALID_INPUT', async () => { + const query = ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1h" + from: "2026-10-16T00:00:00.000Z" + to: "2026-10-15T00:00:00.000Z" + ) { + bucket + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeDefined() + expect(res.errors[0].message).toBe('from must be before to') + expect(res.errors[0].extensions?.code).toBe('INVALID_INPUT') + }) + + it('rejects an over-large range exceeding MAX_HISTORY_POINTS', async () => { + // 1m window with 15,000 minutes range (> 10,000 points) + const from = new Date('2026-01-01T00:00:00.000Z').toISOString() + const to = new Date('2026-01-15T00:00:00.000Z').toISOString() // 14 days = 20,160 minutes + + const query = ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1m" + from: "${from}" + to: "${to}" + ) { + bucket + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeDefined() + expect(res.errors[0].message).toContain('requested window is too large') + expect(res.errors[0].extensions?.code).toBe('INVALID_INPUT') + }) + + it('rejects invalid ISO dates with INVALID_INPUT', async () => { + const query = ` + query { + getPriceHistory( + assetA: "XLM" + assetB: "USDC" + window: "1h" + from: "not-a-date" + ) { + bucket + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeDefined() + expect(res.errors[0].message).toBe('from must be a valid ISO 8601 date') + expect(res.errors[0].extensions?.code).toBe('INVALID_INPUT') + }) + }) + + describe('Issue #369: Oracle comparison query', () => { + it('returns typed comparison data with enum status for available source', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ vwap: '100' }] }) + mockReflectorAdapter.isConfigured.mockReturnValue(true) + mockReflectorAdapter.getPrice.mockResolvedValue({ price: 101, timestamp: 1_760_000_000 }) + + const query = ` + query { + oracle(asset: "XLM") { + asset + network + lens + status + oracles { + name + price + value + status + ageSeconds + } + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.oracle.asset).toBe('XLM') + expect(res.data.oracle.lens).toBe(100) + expect(res.data.oracle.oracles).toHaveLength(1) + expect(res.data.oracle.oracles[0].name).toBe('reflector') + expect(res.data.oracle.oracles[0].price).toBe(101) + expect(res.data.oracle.oracles[0].value).toBe(101) + expect(res.data.oracle.oracles[0].status).toBe('AVAILABLE') + expect(typeof res.data.oracle.oracles[0].ageSeconds).toBe('number') + }) + + it('distinguishes unreachable sources when adapter throws', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ vwap: '100' }] }) + mockReflectorAdapter.isConfigured.mockReturnValue(true) + mockReflectorAdapter.getPrice.mockRejectedValue(new Error('connection timeout')) + + const query = ` + query { + oracle(asset: "XLM") { + asset + oracles { + name + price + status + } + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.oracle.oracles[0].name).toBe('reflector') + expect(res.data.oracle.oracles[0].price).toBeNull() + expect(res.data.oracle.oracles[0].status).toBe('UNREACHABLE') + }) + + it('distinguishes empty sources when adapter returns null without throwing', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ vwap: '100' }] }) + mockReflectorAdapter.isConfigured.mockReturnValue(true) + mockReflectorAdapter.getPrice.mockResolvedValue(null) + + const query = ` + query { + oracle(asset: "XLM") { + oracles { + status + price + } + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.oracle.oracles[0].status).toBe('EMPTY') + expect(res.data.oracle.oracles[0].price).toBeNull() + }) + }) + + describe('Issue #368: Volumes & Spreads queries with REST parity', () => { + it('volumes query returns typed venue list with parity against REST route', async () => { + const rows = [ + { source: 'SDEX', volume: '100.5', trade_count: 3 }, + { source: 'AMM', volume: '49.5', trade_count: 2 }, + ] + mockQuery.mockResolvedValue({ rows }) + + // 1. Call REST + const restRes = await app.inject({ method: 'GET', url: '/volumes/XLM?window=24h' }) + const restBody = restRes.json() + + // 2. Call GraphQL + const gqlQuery = ` + query { + volumes(asset: "XLM", window: "24h") { + asset + network + window + totalVolume + tradeCount + venues { + venue + volume + tradeCount + } + } + } + ` + const gqlRes = await postGql(app, gqlQuery) + expect(gqlRes.errors).toBeUndefined() + const gqlBody = gqlRes.data.volumes + + expect(gqlBody.asset).toBe(restBody.asset) + expect(gqlBody.network).toBe(restBody.network) + expect(gqlBody.window).toBe(restBody.window) + expect(gqlBody.totalVolume).toBe(restBody.totalVolume) + expect(gqlBody.tradeCount).toBe(restBody.tradeCount) + expect(Array.isArray(gqlBody.venues)).toBe(true) + expect(gqlBody.venues).toHaveLength(2) + expect(gqlBody.venues.map((v: any) => v.venue).sort()).toEqual(['AMM', 'SDEX']) + }) + + it('spreads query returns typed VenueQuote list with parity against REST route', async () => { + const rows = [ + { + source: 'sdex', + pair_key: 'XLM/USDC', + bid: '99', + ask: '101', + observations: 10, + last_seen: new Date('2026-09-01T12:00:00.000Z'), + }, + ] + mockQuery.mockResolvedValue({ rows }) + + // 1. Call REST + const restRes = await app.inject({ method: 'GET', url: '/spreads/XLM?window=5m' }) + const restBody = restRes.json() + + // 2. Call GraphQL + const gqlQuery = ` + query { + spreads(asset: "XLM", window: "5m") { + asset + network + window + venues { + venue + pairKey + bid + ask + mid + spreadBps + spreadPct + observations + } + tightest { + venue + spreadBps + } + } + } + ` + const gqlRes = await postGql(app, gqlQuery) + expect(gqlRes.errors).toBeUndefined() + const gqlBody = gqlRes.data.spreads + + expect(gqlBody.asset).toBe(restBody.asset) + expect(gqlBody.network).toBe(restBody.network) + expect(gqlBody.venues[0].venue).toBe(restBody.venues[0].venue) + expect(gqlBody.venues[0].spreadBps).toBeCloseTo(restBody.venues[0].spreadBps, 4) + expect(gqlBody.tightest.spreadBps).toBeCloseTo(restBody.tightest.spreadBps, 4) + }) + }) + + describe('Issue #367: Market Screener query', () => { + it('supports filter, sort, and pagination cursor', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + pair_key: 'XLM/USDC', + volume: 15000, + price: 0.12, + change_24h: 3.5, + liquidity: 80000, + }, + { + pair_key: 'BTC/USDC', + volume: 12000, + price: 65000, + change_24h: 1.2, + liquidity: 250000, + }, + ], + }) + + const query = ` + query { + screener( + filter: { volume: 10000, liquidity: 50000 } + sort: { field: "volume", order: "desc" } + pagination: { limit: 1 } + ) { + data { + pairKey + pair_key + volume + price + change24h + liquidity + } + nextCursor + hasMore + } + } + ` + const res = await postGql(app, query) + expect(res.errors).toBeUndefined() + expect(res.data.screener.data).toHaveLength(1) + expect(res.data.screener.data[0].pairKey).toBe('XLM/USDC') + expect(res.data.screener.hasMore).toBe(true) + expect(res.data.screener.nextCursor).toBeDefined() + }) + + it('rejects market_cap with exact explanation constant matching REST', async () => { + const queryFilter = ` + query { + screener(filter: { marketCap: 100000 }) { + data { + pairKey + } + } + } + ` + const resFilter = await postGql(app, queryFilter) + expect(resFilter.errors).toBeDefined() + expect(resFilter.errors[0].message).toBe(MARKET_CAP_UNSUPPORTED) + expect(resFilter.errors[0].extensions?.code).toBe('INVALID_INPUT') + + const querySort = ` + query { + screener(sort: { field: "market_cap" }) { + data { + pairKey + } + } + } + ` + const resSort = await postGql(app, querySort) + expect(resSort.errors).toBeDefined() + expect(resSort.errors[0].message).toBe(MARKET_CAP_UNSUPPORTED) + expect(resSort.errors[0].extensions?.code).toBe('INVALID_INPUT') + }) + }) +}) diff --git a/src/api/graphql.ts b/src/api/graphql.ts index e26ebcda..dddc00d3 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -14,6 +14,10 @@ import { reflectorOracleAdapter } from '../ingest/oracles/reflector' import type { OracleAdapter } from '../ingest/oracles/adapter' import { compareOracles } from '../services/oracleComparison' import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events' +import { oracleAdapters } from '../routes/oracle' +import { getVolumesData } from '../routes/volumes' +import { getSpreadsData } from '../routes/spreads' +import { queryScreener, MARKET_CAP_UNSUPPORTED } from '../routes/screener' import { HISTORY_INTERVAL_SECONDS, MAX_HISTORY_POINTS } from './history' const WINDOW_SECONDS: Record = { @@ -28,7 +32,7 @@ const PRICE_TOPIC = 'PRICE_UPDATED' // Error codes that resolvers raise on purpose; their messages are written for // the caller and are safe to return as-is. Anything else is treated as an // unexpected internal failure and masked. -const USER_FACING_CODES = new Set(['INVALID_INPUT', 'PAIR_NOT_FOUND', 'UNAUTHORIZED']) +const USER_FACING_CODES = new Set(['INVALID_INPUT', 'PAIR_NOT_FOUND', 'NOT_FOUND', 'UNAUTHORIZED']) const INTERNAL_ERROR_MESSAGE = 'Internal server error' function isUserFacing(err: GraphQLError): boolean { @@ -126,6 +130,7 @@ export const schema = ` type OracleSource { name: String! price: Float + value: Float timestamp: String deviationPct: Float """ @@ -134,6 +139,7 @@ export const schema = ` """ status: OracleSourceStatus! ageSeconds: Int + age: Int } type OracleComparison { @@ -145,6 +151,83 @@ export const schema = ` status: String! fetchedAt: String! oracles: [OracleSource!]! + sources: [OracleSource!]! + } + + type VenueVolume { + venue: String! + volume: Float! + tradeCount: Int! + } + + type VolumeReport { + asset: String! + network: String! + window: String! + startTime: String! + endTime: String! + totalVolume: Float! + tradeCount: Int! + venues: [VenueVolume!]! + } + + type VenueQuote { + venue: String! + pairKey: String! + bid: Float! + ask: Float! + mid: Float! + spreadBps: Float! + spreadPct: Float! + observations: Int! + lastSeen: String! + } + + type SpreadReport { + asset: String! + network: String! + window: String! + startTime: String! + endTime: String! + venues: [VenueQuote!]! + tightest: VenueQuote + } + + input ScreenerFilterInput { + volume: Float + change24hMin: Float + change24hMax: Float + priceMin: Float + priceMax: Float + liquidity: Float + marketCap: Float + } + + input ScreenerSortInput { + field: String + order: String + } + + input ScreenerPaginationInput { + cursor: String + limit: Int + } + + type ScreenerItem { + pairKey: String! + pair_key: String! + volume: Float! + price: Float! + change24h: Float! + change_24h: Float! + liquidity: Float! + } + + type ScreenerResult { + data: [ScreenerItem!]! + nextCursor: String + hasMore: Boolean! + total: Int } type Query { @@ -178,12 +261,34 @@ export const schema = ` from: String to: String ): [PriceBucket] + listPairs: [String]! + """ Compares the Lens price for an asset against every configured oracle. The network argument defaults to the active network. """ oracle(asset: String!, network: String): OracleComparison + + """ + Volume report for an asset across all trading venues. + """ + volumes(asset: String!, window: String, network: String): VolumeReport + + """ + Bid/ask spread and price dispersion report for an asset across venues and pairs. + """ + spreads(asset: String!, window: String, network: String): SpreadReport + + """ + Market screener filtering, sorting, and cursor pagination across tracked pairs. + """ + screener( + filter: ScreenerFilterInput + sort: ScreenerSortInput + pagination: ScreenerPaginationInput + network: String + ): ScreenerResult } type Subscription { @@ -238,13 +343,6 @@ const resolvers = { } const pairKey = pair.pairKey - // These queries carry no network argument, so they resolve against the - // active network's pair list (findPair above) and read that same - // network's price data — scoped, not pooled across both chains. - // Per-request network selection on the GraphQL surface is separate work. - // The cache key is network-prefixed to match the REST route and the - // refresh worker, so no deployment can serve another network's cached - // payload under a network-less key. const cacheKey = `${activeNetwork}:${pairKey}` const cached = await getCachedPrice(cacheKey) if (cached) { @@ -336,7 +434,7 @@ const resolvers = { conditions.push(`bucket <= $${params.length}`) } - params.push(Math.min(limit, 1000)) + params.push(Math.min(limit ?? 100, 1000)) const limitParam = `$${params.length}` const result = await pgPool.query( @@ -381,9 +479,122 @@ const resolvers = { oracleAdapters ) if (!comparison) return null + const sources = comparison.oracles.map(o => ({ + ...o, + value: o.price, + status: o.status.toUpperCase(), + age: o.ageSeconds, + })) return { ...comparison, - oracles: comparison.oracles.map(o => ({ ...o, status: o.status.toUpperCase() })), + oracles: sources, + sources, + } + }, + + async volumes(_: unknown, { asset, window, network }: { asset: string; window?: string; network?: string }) { + try { + const data = await getVolumesData(asset, window, network) + return { + asset: data.asset, + network: data.network, + window: data.window, + startTime: data.startTime, + endTime: data.endTime, + totalVolume: data.totalVolume, + tradeCount: data.tradeCount, + venues: data.venueList, + } + } catch (err: any) { + if (err.message?.includes('must be one of')) { + throw new ErrorWithProps(err.message, { code: 'INVALID_INPUT' }) + } + throw err + } + }, + + async spreads(_: unknown, { asset, window, network }: { asset: string; window?: string; network?: string }) { + try { + const data = await getSpreadsData(asset, window, network) + return data + } catch (err: any) { + if (err.message?.includes('must be one of') || err.message?.includes('parameter is required')) { + throw new ErrorWithProps(err.message, { code: 'INVALID_INPUT' }) + } + throw err + } + }, + + async screener( + _: unknown, + { + filter, + sort, + pagination, + network, + }: { + filter?: { + volume?: number + change24hMin?: number + change24hMax?: number + priceMin?: number + priceMax?: number + liquidity?: number + marketCap?: number + } + sort?: { + field?: string + order?: string + } + pagination?: { + cursor?: string + limit?: number + } + network?: string + } + ) { + if (filter?.marketCap !== undefined || sort?.field === 'market_cap' || sort?.field === 'marketCap') { + throw new ErrorWithProps(MARKET_CAP_UNSUPPORTED, { code: 'INVALID_INPUT' }) + } + try { + const result = await queryScreener({ + volume: filter?.volume, + change_24h_min: filter?.change24hMin, + change_24h_max: filter?.change24hMax, + price_min: filter?.priceMin, + price_max: filter?.priceMax, + liquidity: filter?.liquidity, + sortBy: sort?.field, + order: sort?.order, + cursor: pagination?.cursor, + limit: pagination?.limit, + network: network as any, + }) + return { + data: result.data.map(r => ({ + pairKey: r.pair_key, + pair_key: r.pair_key, + volume: r.volume, + price: r.price, + change24h: r.change_24h, + change_24h: r.change_24h, + liquidity: r.liquidity, + })), + nextCursor: result.nextCursor, + hasMore: result.hasMore, + total: result.total, + } + } catch (err: any) { + if ( + err.message === MARKET_CAP_UNSUPPORTED || + err.message?.includes('must be one of') || + err.message?.includes('must be a valid') || + err.message?.includes('order must be') || + err.message === 'Invalid cursor format' + ) { + throw new ErrorWithProps(err.message, { code: 'INVALID_INPUT' }) + } + throw err } }, }, @@ -397,15 +608,8 @@ const resolvers = { { pair: string; network?: string | null } >( (_root, _args, { pubsub }) => pubsub.subscribe(PRICE_TOPIC), - // Both loops publish to one topic, so the network filter has to happen - // here. Omitting `network` keeps every chain — the message carries its - // own `network` field, so the subscriber can still tell them apart. (payload, { pair, network }) => payload.priceUpdated.pair === pair && - // == null, not === undefined: a client that passes the variable - // explicitly sends null rather than omitting it, and both mean - // "every network". Comparing against undefined alone would filter - // out every message for those clients. (network == null || payload.priceUpdated.network === network) ), }, @@ -420,16 +624,11 @@ export async function registerGraphQL(app: FastifyInstance) { errorFormatter: formatGraphQLErrors, path: '/graphql', subscription: { - // Speak the `graphql-transport-ws` subprotocol (the modern `graphql-ws` - // library) rather than the legacy `subscriptions-transport-ws`. fullWsTransport: true, wsDefaultSubprotocol: 'graphql-transport-ws', }, }) - // Bridge: forward every price the ingesters emit onto the GraphQL pubsub - // topic. Mercurius wraps the payload under the subscription field name so - // `withFilter` and the resolver receive `{ priceUpdated: }`. const onPricePublished = (event: PricePublishedEvent) => { app.graphql.pubsub.publish({ topic: PRICE_TOPIC, @@ -438,8 +637,6 @@ export async function registerGraphQL(app: FastifyInstance) { } priceEmitter.on(PRICE_PUBLISHED, onPricePublished) - // Detach the listener when the server shuts down so repeated - // register/close cycles (e.g. in tests) don't leak listeners. app.addHook('onClose', async () => { priceEmitter.off(PRICE_PUBLISHED, onPricePublished) }) diff --git a/src/routes/oracle.ts b/src/routes/oracle.ts index 5dfda971..6b7846ba 100644 --- a/src/routes/oracle.ts +++ b/src/routes/oracle.ts @@ -4,7 +4,53 @@ import type { OracleAdapter } from '../ingest/oracles/adapter' import { reflectorOracleAdapter } from '../ingest/oracles/reflector' import { compareOracles } from '../services/oracleComparison' -const oracleAdapters: OracleAdapter[] = [reflectorOracleAdapter] +export const oracleAdapters: OracleAdapter[] = [reflectorOracleAdapter] + +export function deviationPct(lens: number | null, oracle: number | null): number | null { + if (lens === null || oracle === null || oracle === 0) return null + return parseFloat((Math.abs(oracle - lens) / Math.abs(oracle) * 100).toFixed(4)) +} + +export function deviationStatus(deviation: number | null): 'aligned' | 'minor_deviation' | 'major_deviation' | 'partial' { + if (deviation === null) return 'partial' + return deviation < 1 ? 'aligned' : deviation < 5 ? 'minor_deviation' : 'major_deviation' +} + +export interface OracleSourceResult { + name: string + price: number | null + timestamp: string | null + deviationPct: number | null + status: 'available' | 'unreachable' | 'empty' | 'not_configured' +} + +export interface OracleComparisonResult { + asset: string + network: string + lens: number | null + reflector: number | null + deviationPct: number | null + status: 'aligned' | 'minor_deviation' | 'major_deviation' | 'partial' + fetchedAt: string + oracles: OracleSourceResult[] +} + +export async function getOracleComparison( + asset: string, + network?: string | null, + adapters: OracleAdapter[] = oracleAdapters +) { + if (network != null && network !== 'testnet' && network !== 'mainnet') { + throw new Error('network must be one of: testnet, mainnet') + } + const assetCode = asset.toUpperCase() + const net = (network ?? activeNetwork) as NetworkName + const comparison = await compareOracles(assetCode, net, adapters) + if (!comparison) { + throw new Error(`No price data found for asset ${assetCode}`) + } + return comparison +} export async function registerOracleRoutes( app: FastifyInstance, diff --git a/src/routes/screener.ts b/src/routes/screener.ts index be7e5aff..6f43dead 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -1,25 +1,25 @@ import type { FastifyInstance } from 'fastify' import { z } from 'zod' import { pgPool } from '../db' -import { activeNetwork } from '../config' +import { activeNetwork, type NetworkName } from '../config' import { formatValidationError, VALIDATION_ERROR_CODE } from '../validation' import '../middleware/network' // declares req.network on the FastifyRequest type // Lens has no circulating-supply data, so a market cap cannot be computed. It // used to be reported as a copy of `liquidity`; a caller who sorts or filters on // it must be told it is gone rather than have the parameter silently ignored. -const MARKET_CAP_UNSUPPORTED = +export const MARKET_CAP_UNSUPPORTED = 'market_cap is not supported: Lens has no circulating supply data to compute it from. Use liquidity instead' /** Machine-readable code for the market_cap rejection (not a validation error). */ const MARKET_CAP_CODE = 'UNSUPPORTED_PARAMETER' -const ALLOWED_SORT_FIELDS = ['volume', 'change_24h', 'price', 'liquidity'] as const +export const ALLOWED_SORT_FIELDS = ['volume', 'change_24h', 'price', 'liquidity'] as const const numFilter = (name: string) => z.coerce.number({ error: `${name} must be a valid number` }).optional() -const ScreenerQuerySchema = z +export const ScreenerQuerySchema = z .object({ // Historical behavior: limit is clamped to [1, 100], never rejected. // `GET /screener?limit=500` returns 100 rows, `limit=0` behaves as 1. @@ -69,6 +69,189 @@ const ScreenerQuerySchema = z } }) +export interface ScreenerOptions { + volume?: number + change_24h_min?: number + change_24h_max?: number + price_min?: number + price_max?: number + liquidity?: number + market_cap?: any + sortBy?: string + order?: string + cursor?: string + limit?: number + network?: NetworkName +} + +export interface ScreenerRow { + pair_key: string + volume: number + price: number + change_24h: number + liquidity: number +} + +export interface ScreenerResult { + data: ScreenerRow[] + nextCursor: string | null + hasMore: boolean + total: number | null +} + +export async function queryScreener(options: ScreenerOptions): Promise { + const limit = Math.min(Math.max(options.limit ?? 20, 1), 100) + if (isNaN(limit)) { + throw new Error('limit must be a valid integer') + } + + if (options.market_cap !== undefined || options.sortBy === 'market_cap') { + throw new Error(MARKET_CAP_UNSUPPORTED) + } + + const allowedSortFields = ['volume', 'change_24h', 'price', 'liquidity'] + const sortBy = options.sortBy ?? 'volume' + if (!allowedSortFields.includes(sortBy)) { + throw new Error(`sortBy must be one of: ${allowedSortFields.join(', ')}`) + } + + const order = (options.order ?? 'desc').toLowerCase() + if (order !== 'asc' && order !== 'desc') { + throw new Error('order must be asc or desc') + } + + const filters = { + volume: options.volume, + change_24h_min: options.change_24h_min, + change_24h_max: options.change_24h_max, + price_min: options.price_min, + price_max: options.price_max, + liquidity: options.liquidity, + } + + for (const [key, val] of Object.entries(filters)) { + if (val !== undefined && isNaN(val)) { + throw new Error(`Filter ${key} must be a valid number`) + } + } + + const conditions: string[] = [] + const params: any[] = [options.network ?? activeNetwork] + let paramIdx = 2 + + if (filters.volume !== undefined) { + conditions.push(`volume >= $${paramIdx++}`) + params.push(filters.volume) + } + if (filters.change_24h_min !== undefined) { + conditions.push(`change_24h >= $${paramIdx++}`) + params.push(filters.change_24h_min) + } + if (filters.change_24h_max !== undefined) { + conditions.push(`change_24h <= $${paramIdx++}`) + params.push(filters.change_24h_max) + } + if (filters.price_min !== undefined) { + conditions.push(`price >= $${paramIdx++}`) + params.push(filters.price_min) + } + if (filters.price_max !== undefined) { + conditions.push(`price <= $${paramIdx++}`) + params.push(filters.price_max) + } + if (filters.liquidity !== undefined) { + conditions.push(`liquidity >= $${paramIdx++}`) + params.push(filters.liquidity) + } + + if (options.cursor) { + try { + const decoded = Buffer.from(options.cursor, 'base64').toString('utf-8') + const [lastVal, lastId] = JSON.parse(decoded) + + if (lastVal !== null && lastVal !== undefined && lastId) { + const op = order === 'desc' ? '<' : '>' + conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) + params.push(lastVal, lastId) + } + } catch { + throw new Error('Invalid cursor format') + } + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + + const sql = ` + WITH pair_pools AS ( + SELECT DISTINCT pair_key, pool_id + FROM price_points + WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL + ), + latest_snapshots AS ( + SELECT DISTINCT ON (ps.pool_id) + ps.pool_id, + (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq + FROM pool_snapshots ps + WHERE ps.network = $1 + ORDER BY ps.pool_id, ps.timestamp DESC + ), + pair_liquidity AS ( + SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity + FROM pair_pools pp + JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id + GROUP BY pp.pair_key + ), + latest_aggregates AS ( + SELECT DISTINCT ON (pair_key) + pair_key, + COALESCE(volume::float, 0) AS volume, + COALESCE(close_price::float, 0) AS price, + CASE + WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 + ELSE 0 + END AS change_24h + FROM price_aggregates + WHERE network = $1 AND window = '24h' + ORDER BY pair_key, bucket DESC + ), + screener_data AS ( + SELECT + la.pair_key, + la.volume, + la.price, + la.change_24h, + COALESCE(pl.liquidity::float, 0) AS liquidity + FROM latest_aggregates la + LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key + ) + SELECT * FROM screener_data + ${whereClause} + ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} + LIMIT $${paramIdx} + ` + + params.push(limit + 1) + const result = await pgPool.query(sql, params) + const rows = result.rows + + const hasMore = rows.length > limit + const data = hasMore ? rows.slice(0, limit) : rows + + let nextCursor = null + if (hasMore && data.length > 0) { + const lastRecord = data[data.length - 1] + const cursorPayload = JSON.stringify([lastRecord[sortBy], lastRecord.pair_key]) + nextCursor = Buffer.from(cursorPayload).toString('base64') + } + + return { + data, + nextCursor, + hasMore, + total: null, + } +} + export async function registerScreenerRoutes(app: FastifyInstance) { app.get<{ Querystring: { @@ -88,7 +271,6 @@ export async function registerScreenerRoutes(app: FastifyInstance) { const log = req.log.child({ component: 'route:screener' }) try { const q = req.query - const parsed = ScreenerQuerySchema.safeParse(q) if (!parsed.success) { // market_cap is not a validation error but an unsupported parameter; @@ -105,139 +287,23 @@ export async function registerScreenerRoutes(app: FastifyInstance) { ) ) } - const { limit, sortBy, order, cursor } = parsed.data - const filters = { - volume: parsed.data.volume, - change_24h_min: parsed.data.change_24h_min, - change_24h_max: parsed.data.change_24h_max, - price_min: parsed.data.price_min, - price_max: parsed.data.price_max, - liquidity: parsed.data.liquidity, - } - - // 3. Build query parameters and WHERE conditions - const conditions: string[] = [] - // Every CTE below is scoped to one network: testnet and mainnet rows share - // these tables, and pooling them would rank a pair on the other chain's data. - const params: any[] = [req.network ?? activeNetwork] - let paramIdx = 2 - - if (filters.volume !== undefined) { - conditions.push(`volume >= $${paramIdx++}`) - params.push(filters.volume) - } - if (filters.change_24h_min !== undefined) { - conditions.push(`change_24h >= $${paramIdx++}`) - params.push(filters.change_24h_min) - } - if (filters.change_24h_max !== undefined) { - conditions.push(`change_24h <= $${paramIdx++}`) - params.push(filters.change_24h_max) - } - if (filters.price_min !== undefined) { - conditions.push(`price >= $${paramIdx++}`) - params.push(filters.price_min) - } - if (filters.price_max !== undefined) { - conditions.push(`price <= $${paramIdx++}`) - params.push(filters.price_max) - } - if (filters.liquidity !== undefined) { - conditions.push(`liquidity >= $${paramIdx++}`) - params.push(filters.liquidity) - } - // 4. Cursor decoding and pagination condition - if (cursor) { - try { - const decoded = Buffer.from(cursor, 'base64').toString('utf-8') - const [lastVal, lastId] = JSON.parse(decoded) - - if (lastVal !== null && lastVal !== undefined && lastId) { - const op = order === 'desc' ? '<' : '>' - conditions.push(`(${sortBy}, pair_key) ${op} ($${paramIdx++}, $${paramIdx++})`) - params.push(lastVal, lastId) - } - } catch (e) { - return reply.status(400).send({ error: 'Invalid cursor format', code: 'VALIDATION_ERROR' }) - } - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - - // 5. Construct the full SQL query using CTEs - const sql = ` - WITH pair_pools AS ( - SELECT DISTINCT pair_key, pool_id - FROM price_points - WHERE network = $1 AND source = 'AMM' AND pool_id IS NOT NULL - ), - latest_snapshots AS ( - SELECT DISTINCT ON (ps.pool_id) - ps.pool_id, - (ps.reserve_a * ps.spot_price + ps.reserve_b) AS pool_liq - FROM pool_snapshots ps - WHERE ps.network = $1 - ORDER BY ps.pool_id, ps.timestamp DESC - ), - pair_liquidity AS ( - SELECT pp.pair_key, SUM(ls.pool_liq) AS liquidity - FROM pair_pools pp - JOIN latest_snapshots ls ON ls.pool_id = pp.pool_id - GROUP BY pp.pair_key - ), - latest_aggregates AS ( - SELECT DISTINCT ON (pair_key) - pair_key, - COALESCE(volume::float, 0) AS volume, - COALESCE(close_price::float, 0) AS price, - CASE - WHEN open_price > 0 THEN ((close_price::float - open_price::float) / open_price::float) * 100 - ELSE 0 - END AS change_24h - FROM price_aggregates - WHERE network = $1 AND window = '24h' - ORDER BY pair_key, bucket DESC - ), - screener_data AS ( - SELECT - la.pair_key, - la.volume, - la.price, - la.change_24h, - COALESCE(pl.liquidity::float, 0) AS liquidity - FROM latest_aggregates la - LEFT JOIN pair_liquidity pl ON pl.pair_key = la.pair_key - ) - SELECT * FROM screener_data - ${whereClause} - ORDER BY ${sortBy} ${order.toUpperCase()}, pair_key ${order.toUpperCase()} - LIMIT $${paramIdx} - ` - - // 6. Execute query - params.push(limit + 1) // Fetch one extra to determine hasMore - const result = await pgPool.query(sql, params) - const rows = result.rows - - const hasMore = rows.length > limit - const data = hasMore ? rows.slice(0, limit) : rows - - // 7. Generate nextCursor - let nextCursor = null - if (hasMore && data.length > 0) { - const lastRecord = data[data.length - 1] - const cursorPayload = JSON.stringify([lastRecord[sortBy], lastRecord.pair_key]) - nextCursor = Buffer.from(cursorPayload).toString('base64') - } + const result = await queryScreener({ + ...parsed.data, + network: req.network ?? activeNetwork, + }) - return { - data, - nextCursor, - hasMore, - total: null, + return result + } catch (error: any) { + if ( + error.message === MARKET_CAP_UNSUPPORTED || + error.message?.includes('must be one of') || + error.message?.includes('must be a valid') || + error.message?.includes('order must be') || + error.message === 'Invalid cursor format' + ) { + return reply.status(400).send({ error: error.message, code: 'VALIDATION_ERROR' }) } - } catch (error) { log.error({ err: error }, 'screener query failed') return reply.status(500).send({ error: 'Internal server error' }) } diff --git a/src/routes/spreads.ts b/src/routes/spreads.ts index bd229e43..df13cd84 100644 --- a/src/routes/spreads.ts +++ b/src/routes/spreads.ts @@ -5,21 +5,21 @@ 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 = { +export const SPREAD_WINDOW_MINUTES = { '5m': 5, '15m': 15, '1h': 60, '24h': 60 * 24, } as const -type SpreadWindow = keyof typeof WINDOW_MINUTES +export type SpreadWindow = keyof typeof SPREAD_WINDOW_MINUTES -const WINDOWS = Object.keys(WINDOW_MINUTES) as SpreadWindow[] +export const SPREAD_WINDOWS = Object.keys(SPREAD_WINDOW_MINUTES) as SpreadWindow[] /** A venue needs at least this many observations before its spread means anything. */ -const MIN_OBSERVATIONS = 2 +export const MIN_OBSERVATIONS = 2 -interface VenueSpread { +export interface VenueSpread { venue: string pairKey: string bid: number @@ -32,38 +32,108 @@ interface VenueSpread { lastSeen: string } +export interface SpreadReportResult { + asset: string + network: NetworkName + window: string + startTime: string + endTime: string + venues: VenueSpread[] + tightest: VenueSpread | null +} + +export async function getSpreadsData( + asset: string, + windowStr: string = '5m', + requestedNetwork?: string +): Promise { + const window = windowStr ?? '5m' + + if (!asset) { + throw new Error('Asset parameter is required') + } + + if (!(window in SPREAD_WINDOW_MINUTES)) { + throw new Error(`window must be one of: ${SPREAD_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. + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + throw new Error('network must be one of: testnet, mainnet') + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + const minutes = SPREAD_WINDOW_MINUTES[window as SpreadWindow] + const endTime = new Date() + const startTime = new Date(endTime.getTime() - minutes * 60 * 1000) + + 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, + spreadPct: ((ask - bid) / mid) * 100, + 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, + } +} + /** * 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<{ @@ -71,95 +141,20 @@ export async function registerSpreadsRoutes(app: FastifyInstance) { 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, - spreadPct: ((ask - bid) / mid) * 100, - 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, + const data = await getSpreadsData(asset, req.query.window, req.query.network) + return data + } catch (err: any) { + if ( + err.message === 'Asset parameter is required' || + err.message.includes('window must be one of') || + err.message.includes('network must be one of') + ) { + return reply.status(400).send({ error: err.message }) } - } catch (err) { return reply .status(500) - .send({ error: `Spread aggregation failed: ${(err as Error).message}` }) + .send({ error: `Spread aggregation failed: ${err.message}` }) } }) } diff --git a/src/routes/volumes.ts b/src/routes/volumes.ts index 802ba03d..ef373e00 100644 --- a/src/routes/volumes.ts +++ b/src/routes/volumes.ts @@ -3,30 +3,113 @@ import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' // Supported windows → lookback in hours. -const WINDOW_HOURS = { +export const VOLUME_WINDOW_HOURS = { '24h': 24, '7d': 24 * 7, '30d': 24 * 30, } as const -type VolumeWindow = keyof typeof WINDOW_HOURS +export type VolumeWindow = keyof typeof VOLUME_WINDOW_HOURS -const WINDOWS = Object.keys(WINDOW_HOURS) as VolumeWindow[] +export const VOLUME_WINDOWS = Object.keys(VOLUME_WINDOW_HOURS) as VolumeWindow[] + +export interface VenueVolumeResult { + venue: string + volume: number + tradeCount: number +} + +export interface VolumeReportResult { + asset: string + network: NetworkName + window: string + startTime: string + endTime: string + totalVolume: number + byVenue: Record + venues: string[] + venueList: VenueVolumeResult[] + tradeCount: number +} + +export async function getVolumesData( + asset: string, + windowStr: string = '24h', + requestedNetwork?: string +): Promise { + const window = windowStr ?? '24h' + + if (!(window in VOLUME_WINDOW_HOURS)) { + throw new Error(`window must be one of: ${VOLUME_WINDOWS.join(', ')}`) + } + + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + throw new Error('network must be one of: testnet, mainnet') + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + const hours = VOLUME_WINDOW_HOURS[window as VolumeWindow] + const endTime = new Date() + const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000) + + // Per-venue volume for the asset, summed over the window. The asset's volume + // is base_volume when it's asset_a and counter_volume when it's asset_b. + const { rows } = await pgPool.query( + `SELECT source, + SUM( + CASE + WHEN asset_a = $1 THEN base_volume + WHEN asset_b = $1 THEN counter_volume + ELSE 0 + END + )::numeric AS volume, + COUNT(*)::int AS trade_count + FROM price_points + WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 + AND timestamp >= $3 + GROUP BY source`, + [asset, network, startTime], + ) + + const byVenue: Record = {} + const venueList: VenueVolumeResult[] = [] + let totalVolume = 0 + let tradeCount = 0 + + for (const row of rows) { + const volume = parseFloat(row.volume) || 0 + const count = Number(row.trade_count) || 0 + byVenue[row.source] = volume + venueList.push({ + venue: row.source, + volume, + tradeCount: count, + }) + totalVolume += volume + tradeCount += count + } + + return { + asset, + network, + window, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + // Cross-venue sum. + totalVolume, + // Per-venue breakdown that the total is summed from. + byVenue, + venues: Object.keys(byVenue), + venueList, + tradeCount, + } +} /** * Register the aggregated cross-venue volume endpoint. * * GET /volumes/:asset?window=24h|7d|30d&network=testnet|mainnet - * - * Sums traded volume for `asset` across every pair it appears in and across all - * venues (SDEX, AMM, …) over the requested window. Volume is measured in the - * asset's own units: when it is the base asset of a pair we count `base_volume`, - * when it is the counter asset we count `counter_volume`. The response breaks the - * total down per venue and also returns the cross-venue sum. - * - * Only one network's rows are summed: testnet and mainnet volume added together - * is not a volume of anything. `network` defaults to this process's own network, - * an unrecognised value is a 400, and the network answered for is echoed back. */ export async function registerVolumeRoutes(app: FastifyInstance) { app.get<{ @@ -34,73 +117,26 @@ export async function registerVolumeRoutes(app: FastifyInstance) { Querystring: { window?: string; network?: string } }>('/volumes/:asset', async (req, reply) => { const { asset } = req.params - const window = req.query.window ?? '24h' - - if (!(window in WINDOW_HOURS)) { - return reply - .status(400) - .send({ error: `window must be one of: ${WINDOWS.join(', ')}` }) - } - - 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 hours = WINDOW_HOURS[window as VolumeWindow] - const endTime = new Date() - const startTime = new Date(endTime.getTime() - hours * 60 * 60 * 1000) - try { - // Per-venue volume for the asset, summed over the window. The asset's volume - // is base_volume when it's asset_a and counter_volume when it's asset_b. - const { rows } = await pgPool.query( - `SELECT source, - SUM( - CASE - WHEN asset_a = $1 THEN base_volume - WHEN asset_b = $1 THEN counter_volume - ELSE 0 - END - )::numeric AS volume, - COUNT(*)::int AS trade_count - FROM price_points - WHERE (asset_a = $1 OR asset_b = $1) - AND network = $2 - AND timestamp >= $3 - GROUP BY source`, - [asset, network, startTime], - ) - - const byVenue: Record = {} - let totalVolume = 0 - let tradeCount = 0 - - for (const row of rows) { - const volume = parseFloat(row.volume) || 0 - byVenue[row.source] = volume - totalVolume += volume - tradeCount += Number(row.trade_count) || 0 - } - + const data = await getVolumesData(asset, req.query.window, req.query.network) return { - asset, - network, - window, - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), - // Cross-venue sum. - totalVolume, - // Per-venue breakdown that the total is summed from. - byVenue, - venues: Object.keys(byVenue), - tradeCount, + asset: data.asset, + network: data.network, + window: data.window, + startTime: data.startTime, + endTime: data.endTime, + totalVolume: data.totalVolume, + byVenue: data.byVenue, + venues: data.venues, + tradeCount: data.tradeCount, + } + } catch (err: any) { + if (err.message.includes('window must be one of') || err.message.includes('network must be one of')) { + return reply.status(400).send({ error: err.message }) } - } catch (err) { return reply .status(500) - .send({ error: `Volume aggregation failed: ${(err as Error).message}` }) + .send({ error: `Volume aggregation failed: ${err.message}` }) } }) }