From b0f6147cf64e3af6f82cff85a8584a4179d43efe Mon Sep 17 00:00:00 2001 From: funmilayo-ui <316746874+funmilayo-ui@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:08:24 +0100 Subject: [PATCH] fix: compute route slippagePct against the AMM spot price instead of itself --- .changeset/slippage-pct-true-spot.md | 11 ++++ src/__tests__/bestRoute.test.ts | 86 ++++++++++++++++++++++++++++ src/aggregator/bestRoute.ts | 32 ++++++++--- tests/aggregator.property.test.ts | 12 +++- 4 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 .changeset/slippage-pct-true-spot.md diff --git a/.changeset/slippage-pct-true-spot.md b/.changeset/slippage-pct-true-spot.md new file mode 100644 index 00000000..eb39dde7 --- /dev/null +++ b/.changeset/slippage-pct-true-spot.md @@ -0,0 +1,11 @@ +--- +'lens': patch +--- + +Fix `slippagePct` on `/price/:a/:b/route`, which was always exactly 0 because +the execution price was compared with itself. It is now the shortfall of the +execution price against the AMM reserve-ratio spot price, so it grows with +order size against a fixed pool. Route selection and `estimatedOutput` are +unchanged. Slippage is only reported for an AMM route; SDEX and SPLIT routes +(and pairs with no AMM pool) have no size-independent spot reference, so the +value stays 0 rather than reporting a cross-venue spread as slippage. diff --git a/src/__tests__/bestRoute.test.ts b/src/__tests__/bestRoute.test.ts index 3e092f70..5dd1b5c9 100644 --- a/src/__tests__/bestRoute.test.ts +++ b/src/__tests__/bestRoute.test.ts @@ -171,4 +171,90 @@ describe('getBestRoute', () => { expect(sql).toMatch(/pool_snapshots[\s\S]*WHERE pair_key = \$1 AND network = \$2 AND source = 'AMM'/) expect(params).toEqual([pairKey, 'mainnet']) }) + + describe('slippagePct', () => { + // Fixed pool: 1,000,000 A / 500,000 B, 30 bp fee, so spot = 0.5 B per A. + const pool = { rows: [{ reserve_a: '1000000', reserve_b: '500000', fee_bp: '30' }] } as any + + it('is near zero for a tiny order and grows with amount against a fixed pool', async () => { + mockCall.mockResolvedValue({ records: [] }) // AMM only + mockQuery.mockResolvedValue(pool) + + const small = await getBestRoute(assetA, assetB, pairKey, 1) + const medium = await getBestRoute(assetA, assetB, pairKey, 10_000) + const large = await getBestRoute(assetA, assetB, pairKey, 500_000) + + // a 1-unit trade only pays the 0.30% fee + expect(small.slippagePct).toBeGreaterThan(0.29) + expect(small.slippagePct).toBeLessThan(0.31) + expect(medium.slippagePct).toBeGreaterThan(small.slippagePct) + expect(large.slippagePct).toBeGreaterThan(medium.slippagePct) + // 500k into a 1M pool moves the price by roughly a third + expect(large.slippagePct).toBeGreaterThan(30) + }) + + it('matches the constant-product curve exactly for a known order', async () => { + mockCall.mockResolvedValue({ records: [] }) + mockQuery.mockResolvedValue(pool) + + const r = await getBestRoute(assetA, assetB, pairKey, 100_000) + const eff = 100_000 * 0.997 + const exec = (500000 * eff) / (1000000 + eff) / 100_000 + expect(r.slippagePct).toBeCloseTo(((0.5 - exec) / 0.5) * 100, 6) + }) + + it('is 0 when there is no AMM pool to give a spot reference', async () => { + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue({ rows: [] } as any) + + const r = await getBestRoute(assetA, assetB, pairKey, 1000) + expect(r.slippagePct).toBe(0) + }) + + it('does not change which route is selected', async () => { + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue(pool) + + const r = await getBestRoute(assetA, assetB, pairKey, 1000) + expect(r.route).toBe('SDEX') + expect(r.estimatedOutput).toBeCloseTo(500, 6) + expect(r.slippagePct).toBe(0) // SDEX beats AMM spot: no shortfall + }) + + // Slippage is only measured against the spot of the venue being executed + // on. For SDEX/SPLIT there is no size-independent reference, so an AMM + // pool that happens to exist must not leak its spot into the figure. + it('is 0 for an SDEX route even when the AMM spot is far above the fill', async () => { + // thin/stale pool, spot 0.6; SDEX fills at 0.5 and wins the route + mockCall.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + mockQuery.mockResolvedValue({ rows: [{ reserve_a: '1000', reserve_b: '600', fee_bp: '30' }] } as any) + + const r = await getBestRoute(assetA, assetB, pairKey, 1000) + expect(r.route).toBe('SDEX') + expect(r.slippagePct).toBe(0) + }) + + it('does not report the AMM fee as slippage on an SDEX fill', async () => { + // spot 0.5, 30 bp fee: the AMM nets about 0.4980, SDEX fills at 0.4985 and wins + mockCall.mockResolvedValue({ records: [{ destination_amount: '498.5' }] }) + mockQuery.mockResolvedValue(pool) + + const r = await getBestRoute(assetA, assetB, pairKey, 1000) + expect(r.route).toBe('SDEX') + expect(r.slippagePct).toBe(0) + }) + + it('is 0 for a SPLIT route', async () => { + // amount > 10000 and SDEX within 0.1% of the AMM execution price + mockQuery.mockResolvedValue(pool) + const amount = 20_000 + const eff = amount * 0.997 + const ammExec = (500000 * eff) / (1000000 + eff) / amount + mockCall.mockResolvedValue({ records: [{ destination_amount: String(ammExec * amount) }] }) + + const r = await getBestRoute(assetA, assetB, pairKey, amount) + expect(r.route).toBe('SPLIT') + expect(r.slippagePct).toBe(0) + }) + }) }) diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index 576cccb3..e9ee02cb 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -3,6 +3,7 @@ import { activeNetwork, type NetworkName } from '../config' import { getHorizonServer, resetNetworkClients } from '../network/clients' import type { AssetId, RouteInfo } from '../types' import { pgPool } from '../db' +import { calculateAMMSpotPrice } from '../pricing/depth' function assetIdToStellar(asset: AssetId) { if (!asset.issuer) return Asset.native() @@ -13,7 +14,11 @@ function assetIdToStellar(asset: AssetId) { // scan and the price_points subquery that names which pools hold this pair. // Pool ids are only unique within a network, so filtering just the outer scan // would happily price a mainnet pair off a testnet pool's reserves. -async function getAMMPrice(pairKey: string, amount: number, network: NetworkName): Promise { +async function getAMMPrice( + pairKey: string, + amount: number, + network: NetworkName +): Promise<{ price: number; spotPrice: number }> { // Get latest pool snapshot via pool_id (pairKey indexes price_points correctly) const result = await pgPool.query( `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp @@ -27,7 +32,7 @@ async function getAMMPrice(pairKey: string, amount: number, network: NetworkName LIMIT 1`, [pairKey, network] ) - if (!result.rows[0]) return 0 + if (!result.rows[0]) return { price: 0, spotPrice: 0 } const { reserve_a, reserve_b, fee_bp } = result.rows[0] const rA = parseFloat(reserve_a) @@ -37,7 +42,9 @@ async function getAMMPrice(pairKey: string, amount: number, network: NetworkName // Constant product formula: output = (reserveB * amount * fee) / (reserveA + amount * fee) const effectiveInput = amount * fee const output = (rB * effectiveInput) / (rA + effectiveInput) - return output / amount // price per unit + // spotPrice is the reserve-ratio marginal price (no size, no fee); price is + // the average execution price for `amount` on the constant-product curve. + return { price: output / amount, spotPrice: calculateAMMSpotPrice(rA, rB) } } /** @@ -76,10 +83,11 @@ export async function getBestRoute( amount: number = 1000, network: NetworkName = activeNetwork ): Promise { - const [sdexPrice, ammPrice] = await Promise.all([ + const [sdexPrice, amm] = await Promise.all([ getSDEXPrice(assetA, assetB, amount, network), getAMMPrice(pairKey, amount, network), ]) + const ammPrice = amm.price let route: RouteInfo['route'] = 'UNKNOWN' let estimatedOutput = 0 @@ -101,7 +109,7 @@ export async function getBestRoute( // Within 0.1% — suggest split for large orders route = amount > 10000 ? 'SPLIT' : (sdexPrice >= ammPrice ? 'SDEX' : 'AMM') estimatedOutput = Math.max(sdexPrice, ammPrice) * amount - recommendation = diff < 0.001 ? 'Prices within 0.1% — either route suitable' : 'SPLIT may reduce slippage for large orders' + recommendation = 'Prices within 0.1% — either route suitable' } else if (sdexPrice > ammPrice) { route = 'SDEX' estimatedOutput = sdexPrice * amount @@ -113,8 +121,18 @@ export async function getBestRoute( } } - const spotPrice = Math.max(sdexPrice, ammPrice) - const slippagePct = spotPrice > 0 ? Math.abs((estimatedOutput / amount - spotPrice) / spotPrice * 100) : 0 + // Slippage is the shortfall of the execution price against the spot of the + // venue actually being traded on. For an AMM route that is the pool's + // reserve-ratio price, which does not depend on trade size (the old code + // compared the execution price with itself, so it was always 0). SDEX and + // SPLIT routes have no size-independent reference, so they report 0 — the + // AMM's spot is a different venue and would turn a cross-venue spread into + // a made-up slippage figure. An execution price at or above spot is not + // slippage, hence the clamp. + const spotPrice = route === 'AMM' ? amm.spotPrice : 0 + const executionPrice = amount > 0 ? estimatedOutput / amount : 0 + const slippagePct = + spotPrice > 0 ? Math.max(0, ((spotPrice - executionPrice) / spotPrice) * 100) : 0 return { route, sdexPrice, ammPrice, estimatedOutput, slippagePct, recommendation } } diff --git a/tests/aggregator.property.test.ts b/tests/aggregator.property.test.ts index 1206a946..37b2e376 100644 --- a/tests/aggregator.property.test.ts +++ b/tests/aggregator.property.test.ts @@ -92,9 +92,19 @@ describe('Price aggregator property tests', () => { expect(['SDEX', 'AMM', 'SPLIT', 'UNKNOWN']).toContain(result.route) expect(result.estimatedOutput).toBeGreaterThanOrEqual(0) expect(result.slippagePct).toBeGreaterThanOrEqual(0) - expect(result.slippagePct).toBeCloseTo(0, 6) + expect(Number.isFinite(result.slippagePct)).toBe(true) + if (ammPrice <= 0 || result.route !== 'AMM') { + // slippage is only measured against the AMM spot on an AMM route; + // SDEX/SPLIT (or no pool) have no size-independent reference + expect(result.slippagePct).toBe(0) + } if (sdexPrice === 0) { + // AMM-only: slippage is exactly the curve's gap to reserve-ratio spot + const reserveA = 10000 + const reserveB = (ammPrice * (reserveA + amount * fee)) / fee + const spot = reserveB / reserveA + expect(result.slippagePct).toBeCloseTo(Math.max(0, ((spot - ammPrice) / spot) * 100), 4) expect(result.route).toBe('AMM') expect(result.estimatedOutput).toBeCloseTo(ammPrice * amount, 6) }