diff --git a/docs/impermanent-loss.md b/docs/impermanent-loss.md new file mode 100644 index 00000000..9805e579 --- /dev/null +++ b/docs/impermanent-loss.md @@ -0,0 +1,19 @@ +# Pool return estimates + +`GET /pools` reports fee APR and impermanent loss together for a 24-hour +window. `netReturn24hPct` adds the estimated 24-hour fee return to the +divergence loss for the same window; all values are estimates, not a prediction +of future returns. + +The divergence estimate assumes a constant-product 50/50 pool and compares the +first stored spot price in the last 24 hours with the latest stored spot price. +It does not include deposits, withdrawals, rewards, or gas. A two-point example +with a price moving from 1 to 2 has an estimated divergence loss of +`(2 * sqrt(2) / (1 + 2) - 1) * 100`, or about -5.72%. + +Fee APR annualizes the last 24 hours of stored AMM counter-asset volume, applies +the pool's fee basis points, and divides by twice the current counter-asset +reserve (the pool's estimated value in that asset). This assumes the two +reserves have equal value at the stored spot price. If the pool has no 24-hour +opening snapshot or no usable reserve value, the corresponding net figure is +`null` rather than a fabricated zero. diff --git a/openapi.json b/openapi.json index 94cb0a74..15c34176 100644 --- a/openapi.json +++ b/openapi.json @@ -445,6 +445,25 @@ "timestamp": { "type": "string", "format": "date-time" + }, + "window": { + "type": "string", + "enum": ["24h"] + }, + "feeAprPct": { + "type": "number", + "nullable": true + }, + "impermanentLossPct": { + "type": "number", + "nullable": true + }, + "netReturn24hPct": { + "type": "number", + "nullable": true + }, + "estimate": { + "type": "boolean" } } } diff --git a/openapi.yaml b/openapi.yaml index eb49b630..b6970117 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -292,6 +292,20 @@ paths: timestamp: type: string format: date-time + window: + type: string + enum: [24h] + feeAprPct: + type: number + nullable: true + impermanentLossPct: + type: number + nullable: true + netReturn24hPct: + type: number + nullable: true + estimate: + type: boolean '402': $ref: '#/components/responses/PaymentRequired' '429': diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..bcd5e41d 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -16,6 +16,7 @@ import { installResponseValidation, } from './schemas' import { getDepth } from '../pricing/depth' +import { impermanentLossPct } from '../pricing/impermanentLoss' function makePairKey(a: string, b: string): string { return [a, b].sort().join('/') @@ -225,7 +226,9 @@ export async function registerRESTRoutes(app: FastifyInstance) { WHERE ids.pool_id IS NOT NULL ) SELECT l.pool_id, l.asset_a, l.asset_b, - l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp + l.reserve_a::float, l.reserve_b::float, l.spot_price::float, l.fee_bp, l.timestamp, + opening.spot_price::float AS window_start_price, + COALESCE(activity.counter_volume, 0)::float AS window_counter_volume FROM ids CROSS JOIN LATERAL ( SELECT * FROM pool_snapshots s @@ -233,10 +236,41 @@ export async function registerRESTRoutes(app: FastifyInstance) { ORDER BY s.timestamp DESC LIMIT 1 ) l + LEFT JOIN LATERAL ( + SELECT s.spot_price FROM pool_snapshots s + WHERE s.network = $1 AND s.pool_id = ids.pool_id + AND s.timestamp >= NOW() - INTERVAL '24 hours' + ORDER BY s.timestamp ASC LIMIT 1 + ) opening ON true + LEFT JOIN LATERAL ( + SELECT SUM(p.counter_volume) AS counter_volume FROM price_points p + WHERE p.network = $1 AND p.pool_id = ids.pool_id AND p.source = 'AMM' + AND p.timestamp >= NOW() - INTERVAL '24 hours' + ) activity ON true WHERE ids.pool_id IS NOT NULL`, [network] ) - return { pools: result.rows } + const pools = result.rows.map(row => { + const startPrice = row.window_start_price == null ? null : Number(row.window_start_price) + const endPrice = Number(row.spot_price) + const divergenceLossPct = startPrice === null ? null : impermanentLossPct(startPrice, endPrice) + const reserveB = Number(row.reserve_b) + const poolValueB = Number.isFinite(reserveB) ? 2 * reserveB : 0 + const feeBp = Number(row.fee_bp) || 0 + const volumeB = Number(row.window_counter_volume) || 0 + const feeReturn24hPct = poolValueB > 0 ? (volumeB * feeBp / 10_000 / poolValueB) * 100 : null + return { + ...row, + window: '24h', + feeAprPct: feeReturn24hPct === null ? null : feeReturn24hPct * 365, + impermanentLossPct: divergenceLossPct, + netReturn24hPct: divergenceLossPct === null || feeReturn24hPct === null + ? null + : feeReturn24hPct + divergenceLossPct, + estimate: true, + } + }) + return { pools } }) // GET /price/:assetA/:assetB/depth?amount=1000 diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c7db8496..50195a9e 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -178,6 +178,11 @@ export const poolsResponseSchema = { reserve_b: { type: ['number', 'null'] }, spot_price: { type: ['number', 'null'] }, fee_bp: { type: ['integer', 'null'] }, + window: { type: 'string', enum: ['24h'] }, + feeAprPct: { type: ['number', 'null'] }, + impermanentLossPct: { type: ['number', 'null'] }, + netReturn24hPct: { type: ['number', 'null'] }, + estimate: { type: 'boolean' }, }, }, }, diff --git a/src/pricing/impermanentLoss.ts b/src/pricing/impermanentLoss.ts new file mode 100644 index 00000000..6077764c --- /dev/null +++ b/src/pricing/impermanentLoss.ts @@ -0,0 +1,8 @@ +/** Constant-product 50/50 LP divergence loss, as a percent of initial value. */ +export function impermanentLossPct(startPrice: number, endPrice: number): number | null { + if (!Number.isFinite(startPrice) || !Number.isFinite(endPrice) || startPrice <= 0 || endPrice <= 0) { + return null + } + const ratio = endPrice / startPrice + return (2 * Math.sqrt(ratio) / (1 + ratio) - 1) * 100 +}