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
11 changes: 11 additions & 0 deletions .changeset/slippage-pct-true-spot.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 86 additions & 0 deletions src/__tests__/bestRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
32 changes: 25 additions & 7 deletions src/aggregator/bestRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<number> {
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
Expand All @@ -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)
Expand All @@ -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) }
}

/**
Expand Down Expand Up @@ -76,10 +83,11 @@ export async function getBestRoute(
amount: number = 1000,
network: NetworkName = activeNetwork
): Promise<RouteInfo> {
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
Expand All @@ -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
Expand All @@ -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 }
}
12 changes: 11 additions & 1 deletion tests/aggregator.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading