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
81 changes: 79 additions & 2 deletions src/__tests__/depth.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockQuery = vi.fn()
vi.mock('../db', () => ({
pgPool: {
query: (...args: any[]) => mockQuery(...args),
},
}))

import {
calculateAMMOutput,
calculateAMMPrice,
calculateAMMSpotPrice,
generateAMMDepthLevels
calculateSlippage,
generateAMMDepthLevels,
getDepth,
} from '../pricing/depth'

describe('Depth and slippage calculations', () => {
describe('Depth and slippage calculations (#84)', () => {
beforeEach(() => {
mockQuery.mockReset()
})

describe('calculateAMMSpotPrice', () => {
it('calculates correct spot price from reserves', () => {
expect(calculateAMMSpotPrice(1000, 500)).toBe(0.5)
Expand Down Expand Up @@ -54,6 +68,45 @@ describe('Depth and slippage calculations', () => {

expect(price).toBeGreaterThan(0)
})

it('returns 0 for zero or negative amount', () => {
expect(calculateAMMPrice(1000, 500, 0, 30)).toBe(0)
expect(calculateAMMPrice(1000, 500, -10, 30)).toBe(0)
})
})

describe('calculateSlippage against known constant-product curve', () => {
it('verifies exact mathematical slippage on constant product curve', () => {
// In a 1000 / 1000 pool with 0 fees, spot price = 1.0.
// For input 100: output = 1000 * 100 / (1000 + 100) = 100000 / 1100 = 90.909090...
// Execution price = 0.909090...
// Expected slippage = (1 - 0.909090...) / 1 * 100 = 9.090909...%
const spotPrice = 1.0
const execPrice = 100000 / 1100 / 100
const slippage = calculateSlippage(spotPrice, execPrice)
expect(slippage).toBeCloseTo(9.0909, 3)
})

it('confirms that slippage increases monotonically with trade size', () => {
const rA = 10000
const rB = 10000
const spot = calculateAMMSpotPrice(rA, rB) // 1.0

const priceSmall = calculateAMMPrice(rA, rB, 10, 0)
const priceMed = calculateAMMPrice(rA, rB, 500, 0)
const priceLarge = calculateAMMPrice(rA, rB, 2000, 0)

const slipSmall = calculateSlippage(spot, priceSmall)
const slipMed = calculateSlippage(spot, priceMed)
const slipLarge = calculateSlippage(spot, priceLarge)

expect(slipSmall).toBeLessThan(slipMed)
expect(slipMed).toBeLessThan(slipLarge)
})

it('returns 0 slippage when spot price is 0', () => {
expect(calculateSlippage(0, 10)).toBe(0)
})
})

describe('generateAMMDepthLevels', () => {
Expand Down Expand Up @@ -90,4 +143,28 @@ describe('Depth and slippage calculations', () => {
expect(bids.length).toBe(5)
})
})

describe('getDepth endpoint service', () => {
it('returns empty depth response when no pools exist for pair', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] })
const depth = await getDepth('UNKNOWN/XLM', 100, 'testnet')
expect(depth.spotPrice).toBe(0)
expect(depth.executionPrice).toBe(0)
expect(depth.slippagePct).toBe(0)
expect(depth.asks).toEqual([])
expect(depth.bids).toEqual([])
})

it('returns real depth and slippage when pool exists', async () => {
mockQuery.mockResolvedValueOnce({
rows: [{ reserve_a: '10000', reserve_b: '5000', fee_bp: '30' }],
})
const depth = await getDepth('XLM/USDC', 100, 'testnet')
expect(depth.spotPrice).toBe(0.5)
expect(depth.executionPrice).toBeGreaterThan(0)
expect(depth.slippagePct).toBeGreaterThan(0)
expect(depth.asks.length).toBe(10)
expect(depth.bids.length).toBe(10)
})
})
})
3 changes: 1 addition & 2 deletions src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,7 @@ export async function registerRESTRoutes(app: FastifyInstance) {
if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` })
if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' })

// NOTE: getDepth reads order-book data with no network column yet — see L048.
const depthResult = await getDepth(pair.pairKey, amount)
const depthResult = await getDepth(pair.pairKey, amount, network)

return {
assetA: pair.assetA.code,
Expand Down
80 changes: 60 additions & 20 deletions src/pricing/depth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { pgPool } from '../db'
import { activeNetwork, NetworkName } from '../config'

export interface DepthLevel {
price: number
Expand All @@ -14,21 +15,46 @@ export interface DepthResult {
source: 'AMM' | 'SDEX' | 'BOTH'
}

/**
* Calculates output amount for constant product AMM with fees:
* dy = (y * dx * (1 - fee)) / (x + dx * (1 - fee))
*/
export function calculateAMMOutput(reserveA: number, reserveB: number, amount: number, feeBp: number): number {
const fee = 1 - feeBp / 10000
const effectiveInput = amount * fee
return (reserveB * effectiveInput) / (reserveA + effectiveInput)
}

/**
* Calculates execution price per unit of input:
* executionPrice = dy / dx
*/
export function calculateAMMPrice(reserveA: number, reserveB: number, amount: number, feeBp: number): number {
if (amount <= 0) return 0
const output = calculateAMMOutput(reserveA, reserveB, amount, feeBp)
return output / amount
}

/**
* Calculates spot price from reserves:
* spotPrice = reserveB / reserveA
*/
export function calculateAMMSpotPrice(reserveA: number, reserveB: number): number {
return reserveA > 0 ? reserveB / reserveA : 0
}

/**
* Calculates percentage slippage between spot price and execution price:
* slippagePct = |executionPrice - spotPrice| / spotPrice * 100
*/
export function calculateSlippage(spotPrice: number, executionPrice: number): number {
if (spotPrice <= 0) return 0
return Math.abs((executionPrice - spotPrice) / spotPrice) * 100
}

/**
* Generates discrete depth levels for constant-product pool order book simulation.
*/
export function generateAMMDepthLevels(
reserveA: number,
reserveB: number,
Expand All @@ -46,44 +72,54 @@ export function generateAMMDepthLevels(
asks.push({ price, size: amountA })
}

// Generate bids (buying asset A with B) — price is A per B, but we want B per A (inverse)
// Generate bids (buying asset A with B)
for (let i = 1; i <= numLevels; i++) {
const amountB = reserveB * stepSize * i
const fee = 1 - feeBp / 10000
const effectiveInput = amountB * fee
const amountA = (reserveA * effectiveInput) / (reserveB + effectiveInput)
// Price is amount of A we get per B → but we usually display bids as amount of B per A (inverse)
// Wait no: actually for pair A/B, bids are orders to buy A with B, so the price is how much B you pay per A
// So price = amountB / amountA
const price = amountB / amountA
bids.push({ price, size: amountA })
}

return { asks, bids }
}

export async function getAMMDepth(pairKey: string, amount: number): Promise<DepthResult | null> {
const result = await pgPool.query(
`SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp
FROM pool_snapshots ps
WHERE ps.pool_id IN (
SELECT DISTINCT pool_id FROM price_points
WHERE pair_key = $1 AND source = 'AMM' AND pool_id IS NOT NULL
)
ORDER BY ps.pool_id, ps.timestamp DESC
LIMIT 1`,
[pairKey]
)
export async function getAMMDepth(
pairKey: string,
amount: number,
network: NetworkName = activeNetwork
): Promise<DepthResult | null> {
const query = network
? `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp
FROM pool_snapshots ps
WHERE ps.network = $2 AND ps.pool_id IN (
SELECT DISTINCT pool_id FROM price_points
WHERE pair_key = $1 AND network = $2 AND source = 'AMM' AND pool_id IS NOT NULL
)
ORDER BY ps.pool_id, ps.timestamp DESC
LIMIT 1`
: `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp
FROM pool_snapshots ps
WHERE ps.pool_id IN (
SELECT DISTINCT pool_id FROM price_points
WHERE pair_key = $1 AND source = 'AMM' AND pool_id IS NOT NULL
)
ORDER BY ps.pool_id, ps.timestamp DESC
LIMIT 1`

const params = network ? [pairKey, network] : [pairKey]
const result = await pgPool.query(query, params)
if (!result.rows[0]) return null

const { reserve_a, reserve_b, fee_bp } = result.rows[0]
const rA = parseFloat(reserve_a)
const rB = parseFloat(reserve_b)
const feeBp = parseInt(fee_bp)
const feeBp = parseInt(fee_bp ?? '30', 10)

const spotPrice = calculateAMMSpotPrice(rA, rB)
const executionPrice = calculateAMMPrice(rA, rB, amount, feeBp)
const slippagePct = spotPrice > 0 ? Math.abs((executionPrice - spotPrice) / spotPrice * 100) : 0
const slippagePct = calculateSlippage(spotPrice, executionPrice)
const { asks, bids } = generateAMMDepthLevels(rA, rB, feeBp)

return {
Expand All @@ -96,8 +132,12 @@ export async function getAMMDepth(pairKey: string, amount: number): Promise<Dept
}
}

export async function getDepth(pairKey: string, amount: number): Promise<DepthResult> {
const ammDepth = await getAMMDepth(pairKey, amount)
export async function getDepth(
pairKey: string,
amount: number,
network: NetworkName = activeNetwork
): Promise<DepthResult> {
const ammDepth = await getAMMDepth(pairKey, amount, network)

if (ammDepth) {
return ammDepth
Expand Down