From 41e618141daf964744471371c699b23f468c4968 Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Thu, 22 Oct 2026 14:50:07 +0100 Subject: [PATCH] feat(screener): define liquidity in spec and docs, name unit, and echo network --- openapi.json | 16 +++++++++++- openapi.yaml | 18 +++++++++++++ src/__tests__/screener.test.ts | 46 +++++++++++++++++++++++++++++++++- src/routes/screener.ts | 19 +++++++++++++- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index 8f590c69..0c1d7806 100644 --- a/openapi.json +++ b/openapi.json @@ -1663,6 +1663,7 @@ "name": "liquidity", "in": "query", "required": false, + "description": "Filter for pairs with total AMM pool liquidity greater than or equal to this value. Liquidity is defined as the sum across all active AMM pools for the pair of (reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at the latest snapshot spot price.", "schema": { "type": "number" } @@ -4045,7 +4046,12 @@ "type": "number" }, "liquidity": { - "type": "number" + "type": "number", + "description": "Total liquidity across all active AMM pools for this pair on the network, computed as sum(reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at each pool's latest snapshot spot price." + }, + "liquidity_unit": { + "type": "string", + "description": "The asset symbol/identifier that liquidity is denominated in, corresponding to the counter/quote asset (asset_b) of the pair (the asset after the slash in pair_key)." } } }, @@ -4059,6 +4065,14 @@ ], "additionalProperties": false, "properties": { + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ], + "description": "The network answered for." + }, "data": { "type": "array", "items": { diff --git a/openapi.yaml b/openapi.yaml index 2d8f0a73..2c2bae44 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1121,6 +1121,11 @@ paths: - name: liquidity in: query required: false + description: >- + Filter for pairs with total AMM pool liquidity greater than or equal to this value. + Liquidity is defined as the sum across all active AMM pools for the pair of + (reserve_a * spot_price + reserve_b), valued in the quote asset (asset_b) at the + latest snapshot spot price. schema: type: number - name: sortBy @@ -2617,12 +2622,25 @@ components: type: number liquidity: type: number + description: >- + Total liquidity across all active AMM pools for this pair on the network, + computed as sum(reserve_a * spot_price + reserve_b), valued in the quote + asset (asset_b) at each pool's latest snapshot spot price. + liquidity_unit: + type: string + description: >- + The asset symbol/identifier that liquidity is denominated in, corresponding + to the counter/quote asset (asset_b) of the pair (the asset after the slash in pair_key). ScreenerResponse: type: object required: [data, nextCursor, hasMore, total] additionalProperties: false properties: + network: + type: string + enum: [testnet, mainnet] + description: The network answered for. data: type: array items: diff --git a/src/__tests__/screener.test.ts b/src/__tests__/screener.test.ts index 6046f52f..7cfc7c64 100644 --- a/src/__tests__/screener.test.ts +++ b/src/__tests__/screener.test.ts @@ -41,16 +41,18 @@ describe('GET /screener', () => { mockQuery.mockResolvedValue({ rows: [] }) }) - it('returns rows without a market_cap field', async () => { + it('returns rows without a market_cap field, with liquidity_unit and network', async () => { mockQuery.mockResolvedValue({ rows: [makeRow()] }) const app = await buildApp() const res = await app.inject({ method: 'GET', url: '/screener' }) expect(res.statusCode).toBe(200) const body = res.json() + expect(body.network).toBe('testnet') expect(body.data).toHaveLength(1) expect(body.data[0]).not.toHaveProperty('market_cap') expect(body.data[0].liquidity).toBe(50000) + expect(body.data[0].liquidity_unit).toBe('USDC') expect(lastCall().sql).not.toMatch(/market_cap/) await app.close() }) @@ -230,6 +232,15 @@ describe('GET /screener', () => { await app.close() }) + it('echoes the requested network in the response', async () => { + mockQuery.mockResolvedValue({ rows: [makeRow()] }) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/screener?network=mainnet' }) + expect(res.statusCode).toBe(200) + expect(res.json().network).toBe('mainnet') + await app.close() + }) + it('rejects an unknown network before querying', async () => { const app = await buildApp() const res = await app.inject({ method: 'GET', url: '/screener?network=devnet' }) @@ -239,6 +250,39 @@ describe('GET /screener', () => { }) }) + describe('liquidity definition', () => { + it('defines liquidity in the SQL query as reserve_a * spot_price + reserve_b summed over active AMM pool snapshots', async () => { + const app = await buildApp() + await app.inject({ method: 'GET', url: '/screener' }) + + const { sql } = lastCall() + // Verifies SQL computes pool_liq exactly as reserve_a * spot_price + reserve_b + expect(sql).toMatch(/\(ps\.reserve_a\s*\*\s*ps\.spot_price\s*\+\s*ps\.reserve_b\)\s+AS\s+pool_liq/) + // Verifies sources are AMM pools + expect(sql).toMatch(/FROM price_points\s+WHERE network = \$1 AND source = 'AMM' AND pool_id IS NOT NULL/) + // Verifies summed across pools per pair + expect(sql).toMatch(/SELECT pp\.pair_key,\s*SUM\(ls\.pool_liq\)\s+AS\s+liquidity/) + await app.close() + }) + + it('names the unit as the quote asset in pair_key', async () => { + mockQuery.mockResolvedValue({ + rows: [ + makeRow({ pair_key: 'XLM/USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }), + makeRow({ pair_key: 'BTC/EURC' }), + ], + }) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/screener' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.data[0].liquidity_unit).toBe('USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN') + expect(body.data[1].liquidity_unit).toBe('EURC') + await app.close() + }) + }) + it('returns 500 without leaking the error when the query fails', async () => { mockQuery.mockRejectedValue(new Error('connection refused postgres://user:secret@host')) const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/routes/screener.ts b/src/routes/screener.ts index 0b33fe58..bb9cb872 100644 --- a/src/routes/screener.ts +++ b/src/routes/screener.ts @@ -11,6 +11,19 @@ import '../middleware/network' // declares req.network on the FastifyRequest typ const MARKET_CAP_UNSUPPORTED = 'market_cap is not supported: Lens has no circulating supply data to compute it from. Use liquidity instead' +/** + * Liquidity Definition for /screener: + * - Sources/Venues: Stellar AMM liquidity pools (source = 'AMM' in price_points, + * mapped to pool_snapshots for the pair). + * - Valuation & Formula: Summed across all active AMM pools for the pair of + * `(reserve_a * spot_price + reserve_b)`. Since spot_price is quoted in + * `asset_b` per unit of `asset_a` (i.e. reserve_b / reserve_a), `reserve_a * spot_price` + * evaluates to the value of reserve_a denominated in asset_b. + * - Unit: Denominated in the quote asset (counter asset, `asset_b`, the second asset + * in `pair_key` "BASE/QUOTE"). Named explicitly as `liquidity_unit` in each row. + * - Prices: Spot price from the latest snapshot of each liquidity pool on the requested network. + */ + /** Machine-readable code for the market_cap rejection (not a validation error). */ const MARKET_CAP_CODE = 'UNSUPPORTED_PARAMETER' @@ -235,7 +248,11 @@ export async function registerScreenerRoutes(app: FastifyInstance) { } return { - data, + network: req.network ?? activeNetwork, + data: data.map((row) => ({ + ...row, + liquidity_unit: row.pair_key.split('/')[1] ?? 'quote', + })), nextCursor, hasMore, total: null,