diff --git a/src/__tests__/depth.test.ts b/src/__tests__/depth.test.ts index 810caa12..06c6c7c7 100644 --- a/src/__tests__/depth.test.ts +++ b/src/__tests__/depth.test.ts @@ -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) @@ -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', () => { @@ -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) + }) + }) }) diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..4dcea006 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -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, diff --git a/src/pricing/depth.ts b/src/pricing/depth.ts index ca35f8d7..445ae023 100644 --- a/src/pricing/depth.ts +++ b/src/pricing/depth.ts @@ -1,4 +1,5 @@ import { pgPool } from '../db' +import { activeNetwork, NetworkName } from '../config' export interface DepthLevel { price: number @@ -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, @@ -46,15 +72,12 @@ 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 }) } @@ -62,28 +85,41 @@ export function generateAMMDepthLevels( return { asks, bids } } -export async function getAMMDepth(pairKey: string, amount: number): Promise { - 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 { + 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 { @@ -96,8 +132,12 @@ export async function getAMMDepth(pairKey: string, amount: number): Promise { - const ammDepth = await getAMMDepth(pairKey, amount) +export async function getDepth( + pairKey: string, + amount: number, + network: NetworkName = activeNetwork +): Promise { + const ammDepth = await getAMMDepth(pairKey, amount, network) if (ammDepth) { return ammDepth