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
16 changes: 15 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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)."
}
}
},
Expand All @@ -4059,6 +4065,14 @@
],
"additionalProperties": false,
"properties": {
"network": {
"type": "string",
"enum": [
"testnet",
"mainnet"
],
"description": "The network answered for."
},
"data": {
"type": "array",
"items": {
Expand Down
18 changes: 18 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 45 additions & 1 deletion src/__tests__/screener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down Expand Up @@ -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' })
Expand All @@ -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(() => {})
Expand Down
19 changes: 18 additions & 1 deletion src/routes/screener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
Loading