diff --git a/openapi.json b/openapi.json index cb6441b7..c2e1e3e5 100644 --- a/openapi.json +++ b/openapi.json @@ -280,6 +280,64 @@ } } }, + "/price/{assetA}/{assetB}/median": { + "get": { + "operationId": "getMedianPrice", + "summary": "Get staleness-aware median price across fresh sources with priority fallback", + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "freshness", + "in": "query", + "required": false, + "description": "Freshness threshold in seconds (default: 300)", + "schema": { + "type": "integer", + "default": 300 + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Staleness-aware median price result with included and excluded sources", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "400": { + "description": "Invalid parameters" + }, + "404": { + "description": "Pair not watched" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, "/pools": { "get": { "operationId": "getPools", diff --git a/openapi.yaml b/openapi.yaml index 9d511d0b..eb993711 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -174,6 +174,43 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /price/{assetA}/{assetB}/median: + get: + operationId: getMedianPrice + summary: Get staleness-aware median price across fresh sources with priority fallback + parameters: + - name: assetA + in: path + required: true + schema: + type: string + - name: assetB + in: path + required: true + schema: + type: string + - name: freshness + in: query + required: false + description: 'Freshness threshold in seconds (default: 300)' + schema: + type: integer + default: 300 + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Staleness-aware median price result with included and excluded sources + content: + application/json: + schema: + type: object + '400': + description: Invalid parameters + '404': + description: Pair not watched + '429': + $ref: '#/components/responses/TooManyRequests' + /pools: get: operationId: getPools diff --git a/src/__tests__/configMap.test.ts b/src/__tests__/configMap.test.ts new file mode 100644 index 00000000..6763fa9b --- /dev/null +++ b/src/__tests__/configMap.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { config, getNetworkConfig, activeNetwork } from '../config' + +describe('Per-network config map and resolution (#113)', () => { + it('exposes fully specified config.networks.testnet block', () => { + const testnet = config.networks.testnet + expect(testnet).toBeDefined() + expect(testnet.horizon.url).toMatch(/testnet\.stellar\.org/) + expect(testnet.rpc.url).toMatch(/testnet\.stellar\.org/) + expect(testnet.network.passphrase).toBe('Test SDF Network ; September 2015') + expect(testnet.soroswap.factoryAddress).toBe('CDP3HMUH6SMS3S7NPGNDJLULCOXXEPSHY4JKUKMBNQMATHDHWXRRJTBY') + expect(testnet.aquarius.enabled).toBe(false) // Aquarius has no public testnet deployment + expect(Array.isArray(testnet.pairs)).toBe(true) + expect(testnet.facilitator.feeStroops).toBe(50000) + }) + + it('exposes fully specified config.networks.mainnet block with coherent mainnet contracts', () => { + const mainnet = config.networks.mainnet + expect(mainnet).toBeDefined() + expect(mainnet.horizon.url).toMatch(/horizon\.stellar\.org/) + expect(mainnet.rpc.url).toBeDefined() + expect(mainnet.network.passphrase).toBe('Public Global Stellar Network ; September 2015') + expect(mainnet.soroswap.factoryAddress).toBe('CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2') + expect(mainnet.aquarius.enabled).toBe(true) + expect(Array.isArray(mainnet.pairs)).toBe(true) + expect(mainnet.pairs.length).toBeGreaterThan(0) + // Coherent default pair on mainnet Circle USDC/XLM + expect(mainnet.pairs[0].pairKey).toContain('GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN') + }) + + it('getNetworkConfig returns identical cached instance for given network', () => { + const t1 = getNetworkConfig('testnet') + const t2 = getNetworkConfig('testnet') + expect(t1).toBe(t2) + + const m1 = getNetworkConfig('mainnet') + const m2 = getNetworkConfig('mainnet') + expect(m1).toBe(m2) + expect(t1).not.toBe(m1) + }) + + it('active network shortcuts preserve backward compatibility', () => { + expect(config.horizon.url).toBe(config.networks[activeNetwork].horizon.url) + expect(config.rpc.url).toBe(config.networks[activeNetwork].rpc.url) + expect(config.soroswap.factoryAddress).toBe(config.networks[activeNetwork].soroswap.factoryAddress) + expect(config.pairs).toEqual(config.networks[activeNetwork].pairs) + }) + + it('global shared properties remain network-agnostic', () => { + expect(config.db.url).toBeDefined() + expect(config.redis.url).toBeDefined() + expect(config.api.port).toBeGreaterThan(0) + expect(config.indexer.pollIntervalMs).toBeGreaterThan(0) + expect(config.cache.priceTtl).toBeGreaterThan(0) + }) +}) diff --git a/src/__tests__/depth.test.ts b/src/__tests__/depth.test.ts index 810caa12..6b3cadb0 100644 --- a/src/__tests__/depth.test.ts +++ b/src/__tests__/depth.test.ts @@ -3,10 +3,26 @@ import { calculateAMMOutput, calculateAMMPrice, calculateAMMSpotPrice, - generateAMMDepthLevels + calculateSlippage, + generateAMMDepthLevels, + getDepth, } from '../pricing/depth' -describe('Depth and slippage calculations', () => { +const { mockQuery } = vi.hoisted(() => ({ + mockQuery: vi.fn(), +})) + +vi.mock('../db', () => ({ + pgPool: { + query: mockQuery, + }, +})) + +describe('Depth and slippage calculations (#84)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + describe('calculateAMMSpotPrice', () => { it('calculates correct spot price from reserves', () => { expect(calculateAMMSpotPrice(1000, 500)).toBe(0.5) @@ -54,6 +70,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 +145,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/__tests__/median.test.ts b/src/__tests__/median.test.ts new file mode 100644 index 00000000..f62f9ad5 --- /dev/null +++ b/src/__tests__/median.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + calculateMedian, + computeStalenessAwareMedian, + getStalenessAwareMedianPrice, + type PriceSourceEntry, +} from '../pricing/median' + +const { mockQuery } = vi.hoisted(() => ({ + mockQuery: vi.fn(), +})) + +vi.mock('../db', () => ({ + pgPool: { + query: mockQuery, + }, +})) + +describe('Staleness-aware median pricing with source fallback (#81)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('calculateMedian', () => { + it('returns null for empty array', () => { + expect(calculateMedian([])).toBeNull() + }) + + it('returns exact value for single element', () => { + expect(calculateMedian([42])).toBe(42) + }) + + it('returns middle element for odd number of elements', () => { + expect(calculateMedian([10, 50, 20])).toBe(20) + expect(calculateMedian([1, 9, 3, 7, 5])).toBe(5) + }) + + it('returns average of two middle elements for even number of elements', () => { + expect(calculateMedian([10, 20, 30, 40])).toBe(25) + expect(calculateMedian([2, 8])).toBe(5) + }) + }) + + describe('computeStalenessAwareMedian', () => { + const fixedNow = 1700000000000 // fixed reference epoch + + it('returns null median and no_price_data when sources array is empty', () => { + const result = computeStalenessAwareMedian('XLM/USDC', [], { + freshnessThresholdSec: 300, + now: fixedNow, + }) + + expect(result.medianPrice).toBeNull() + expect(result.sourceCount).toBe(0) + expect(result.fallbackUsed).toBe(false) + expect(result.reason).toBe('no_price_data') + }) + + it('returns null median and all_sources_invalid when all sources are invalid', () => { + const sources: PriceSourceEntry[] = [ + { + source: 'SDEX', + price: 0, + timestamp: new Date(fixedNow - 10 * 1000), + }, + { + source: 'AMM', + price: -5.0, + timestamp: new Date(fixedNow - 10 * 1000), + }, + ] + + const result = computeStalenessAwareMedian('XLM/USDC', sources, { + now: fixedNow, + }) + + expect(result.medianPrice).toBeNull() + expect(result.sourceCount).toBe(0) + expect(result.fallbackUsed).toBe(false) + expect(result.reason).toBe('all_sources_invalid') + expect(result.sources.excluded).toHaveLength(2) + }) + + it('clamps freshnessThresholdSec between 5 and 86400 seconds', () => { + const resLow = computeStalenessAwareMedian('XLM/USDC', [], { + freshnessThresholdSec: 1, // below 5 + }) + expect(resLow.freshnessThresholdSeconds).toBe(5) + + const resHigh = computeStalenessAwareMedian('XLM/USDC', [], { + freshnessThresholdSec: 999999, // above 86400 + }) + expect(resHigh.freshnessThresholdSeconds).toBe(86400) + }) + + it('computes median from only fresh sources and drops stale ones', () => { + const sources: PriceSourceEntry[] = [ + { + source: 'SDEX', + price: 1.05, + timestamp: new Date(fixedNow - 60 * 1000), // 1 min old (fresh) + }, + { + source: 'AMM', + price: 1.08, + timestamp: new Date(fixedNow - 120 * 1000), // 2 min old (fresh) + }, + { + source: 'SOROSWAP', + price: 1.02, + timestamp: new Date(fixedNow - 30 * 1000), // 30s old (fresh) + }, + { + source: 'ORACLE', + price: 2.50, // stale outlier + timestamp: new Date(fixedNow - 600 * 1000), // 10 min old (> 5 min threshold) + }, + ] + + const result = computeStalenessAwareMedian('XLM/USDC', sources, { + freshnessThresholdSec: 300, + now: fixedNow, + }) + + expect(result.sourceCount).toBe(3) + expect(result.fallbackUsed).toBe(false) + expect(result.reason).toBeNull() + // Fresh prices: [1.02, 1.05, 1.08] -> median is 1.05 + expect(result.medianPrice).toBe(1.05) + expect(result.sources.included.map((s) => s.source)).toEqual(['SDEX', 'AMM', 'SOROSWAP']) + expect(result.sources.excluded).toHaveLength(1) + expect(result.sources.excluded[0].source).toBe('ORACLE') + expect(result.sources.excluded[0].reason).toBe('stale') + expect(result.sources.excluded[0].ageSeconds).toBe(600) + }) + + it('engages priority fallback chain when all sources are stale and reports fallbackAgeSeconds', () => { + const sources: PriceSourceEntry[] = [ + { + source: 'AQUARIUS', + price: 1.10, + timestamp: new Date(fixedNow - 1000 * 1000), // very stale (1000s) + }, + { + source: 'SDEX', + price: 1.04, + timestamp: new Date(fixedNow - 500 * 1000), // stale (500s) + }, + { + source: 'SOROSWAP', + price: 1.06, + timestamp: new Date(fixedNow - 400 * 1000), // stale (400s) + }, + ] + + const result = computeStalenessAwareMedian('XLM/USDC', sources, { + freshnessThresholdSec: 300, + now: fixedNow, + fallbackPriority: ['SDEX', 'AMM', 'SOROSWAP', 'AQUARIUS'], + }) + + // Since all are stale, fallback engages and selects SDEX as top priority + expect(result.fallbackUsed).toBe(true) + expect(result.fallbackSource).toBe('SDEX') + expect(result.fallbackAgeSeconds).toBe(500) + expect(result.medianPrice).toBe(1.04) + expect(result.reason).toBeNull() + expect(result.sources.included).toHaveLength(1) + expect(result.sources.included[0].source).toBe('SDEX') + expect(result.sources.included[0].ageSeconds).toBe(500) + expect(result.sources.excluded).toHaveLength(3) + }) + + it('excludes non-finite or non-positive price inputs', () => { + const sources: PriceSourceEntry[] = [ + { + source: 'SDEX', + price: 0, + timestamp: new Date(fixedNow - 10 * 1000), + }, + { + source: 'AMM', + price: -5.0, + timestamp: new Date(fixedNow - 10 * 1000), + }, + { + source: 'SOROSWAP', + price: 1.25, + timestamp: new Date(fixedNow - 10 * 1000), + }, + ] + + const result = computeStalenessAwareMedian('XLM/USDC', sources, { + now: fixedNow, + }) + + expect(result.sourceCount).toBe(1) + expect(result.medianPrice).toBe(1.25) + expect(result.reason).toBeNull() + expect(result.sources.excluded).toHaveLength(2) + expect(result.sources.excluded[0].reason).toBe('invalid') + expect(result.sources.excluded[1].reason).toBe('invalid') + }) + }) + + describe('getStalenessAwareMedianPrice database integration', () => { + it('queries price_points scoped by network and returns median', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { source: 'SDEX', price: '0.12', timestamp: new Date(), pool_id: null }, + { source: 'AMM', price: '0.14', timestamp: new Date(), pool_id: 'pool-1' }, + ], + }) + + const res = await getStalenessAwareMedianPrice('XLM/USDC', 'testnet', 300) + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('WHERE pair_key = $1 AND network = $2'), + ['XLM/USDC', 'testnet'] + ) + expect(res.medianPrice).toBe(0.13) + expect(res.fallbackUsed).toBe(false) + expect(res.reason).toBeNull() + }) + + it('returns null medianPrice and no_price_data when DB has no rows', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }) + + const res = await getStalenessAwareMedianPrice('UNKNOWN/PAIR', 'testnet', 300) + expect(res.medianPrice).toBeNull() + expect(res.sourceCount).toBe(0) + expect(res.reason).toBe('no_price_data') + }) + }) +}) diff --git a/src/__tests__/medianRoute.test.ts b/src/__tests__/medianRoute.test.ts new file mode 100644 index 00000000..2e5dfc98 --- /dev/null +++ b/src/__tests__/medianRoute.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery, mockGetCachedPrice, mockGetBestRoute } = vi.hoisted(() => ({ + mockQuery: vi.fn(), + mockGetCachedPrice: vi.fn(), + mockGetBestRoute: vi.fn(), +})) + +vi.mock('../db', () => ({ + pgPool: { query: mockQuery }, +})) + +vi.mock('../redis', () => ({ + getCachedPrice: mockGetCachedPrice, + setCachedPrice: vi.fn(), +})) + +vi.mock('../aggregator/bestRoute', () => ({ + getBestRoute: mockGetBestRoute, +})) + +const { testnetPairs } = vi.hoisted(() => ({ + testnetPairs: [ + { + pairKey: 'USDC/XLM', + assetA: { code: 'XLM', issuer: null }, + assetB: { code: 'USDC', issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' }, + }, + ], +})) + +vi.mock('../config', () => ({ + config: { + pairs: testnetPairs, + cache: { priceTtl: 10 }, + }, + activeNetwork: 'testnet', + getNetworkConfig: () => ({ + pairs: testnetPairs, + }), +})) + +import { registerRESTRoutes } from '../api/rest' +import { registerNetworkSelector } from '../middleware/network' + +describe('GET /price/:assetA/:assetB/median endpoint', () => { + let app: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerRESTRoutes(app) + await app.ready() + }) + + it('returns 404 if pair is not watched', async () => { + const res = await app.inject({ + method: 'GET', + url: '/price/BTC/ETH/median', + }) + expect(res.statusCode).toBe(404) + expect(JSON.parse(res.body).error).toContain('not watched') + }) + + it('returns 400 when freshness is below 5 seconds', async () => { + const res = await app.inject({ + method: 'GET', + url: '/price/XLM/USDC/median?freshness=4', + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toBe('freshness must be an integer between 5 and 86400 seconds') + }) + + it('returns 400 when freshness is above 86400 seconds', async () => { + const res = await app.inject({ + method: 'GET', + url: '/price/XLM/USDC/median?freshness=100000', + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toBe('freshness must be an integer between 5 and 86400 seconds') + }) + + it('returns 400 when freshness is not an integer', async () => { + const res = await app.inject({ + method: 'GET', + url: '/price/XLM/USDC/median?freshness=abc', + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toBe('freshness must be an integer between 5 and 86400 seconds') + }) + + it('returns 404 with reason when no price points exist', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }) + + const res = await app.inject({ + method: 'GET', + url: '/price/XLM/USDC/median', + }) + expect(res.statusCode).toBe(404) + const body = JSON.parse(res.body) + expect(body.error).toBe('No price data available for pair XLM/USDC on testnet') + expect(body.reason).toBe('no_price_data') + expect(body.medianPrice).toBeNull() + expect(body.sourceCount).toBe(0) + }) + + it('returns 404 with all_sources_invalid when all price points in DB are invalid', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { source: 'SDEX', price: '0', timestamp: new Date().toISOString(), pool_id: null }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/price/XLM/USDC/median', + }) + expect(res.statusCode).toBe(404) + const body = JSON.parse(res.body) + expect(body.error).toBe('No price data available for pair XLM/USDC on testnet') + expect(body.reason).toBe('all_sources_invalid') + expect(body.medianPrice).toBeNull() + }) + + it('returns 200 with median price and sources when valid data exists', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { source: 'SDEX', price: '0.12', timestamp: new Date().toISOString(), pool_id: null }, + { source: 'AMM', price: '0.14', timestamp: new Date().toISOString(), pool_id: 'p1' }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/price/XLM/USDC/median?freshness=300', + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) + expect(body.medianPrice).toBe(0.13) + expect(body.sourceCount).toBe(2) + expect(body.fallbackUsed).toBe(false) + expect(body.reason).toBeNull() + }) +}) diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..da80d95b 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 { getStalenessAwareMedianPrice } from '../pricing/median' function makePairKey(a: string, b: string): string { return [a, b].sort().join('/') @@ -255,8 +256,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, @@ -266,4 +266,40 @@ export async function registerRESTRoutes(app: FastifyInstance) { } } ) + + // GET /price/:assetA/:assetB/median?freshness=300 + app.get<{ + Params: { assetA: string; assetB: string } + Querystring: { freshness?: string } + }>( + '/price/:assetA/:assetB/median', + async (req, reply) => { + const { assetA, assetB } = req.params + const network = req.network ?? activeNetwork + const pair = findPair(assetA, assetB, network) + + if (!pair) return reply.status(404).send({ error: `Pair ${assetA}/${assetB} not watched on ${network}` }) + const freshnessSec = parseInt(req.query.freshness ?? '300', 10) + if (isNaN(freshnessSec) || freshnessSec < 5 || freshnessSec > 86400) { + return reply.status(400).send({ error: 'freshness must be an integer between 5 and 86400 seconds' }) + } + + const result = await getStalenessAwareMedianPrice(pair.pairKey, network, freshnessSec) + if (result.medianPrice === null) { + return reply.status(404).send({ + error: `No price data available for pair ${assetA}/${assetB} on ${network}`, + reason: result.reason, + assetA: pair.assetA.code, + assetB: pair.assetB.code, + ...result, + }) + } + + return { + assetA: pair.assetA.code, + assetB: pair.assetB.code, + ...result, + } + } + ) } 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 diff --git a/src/pricing/median.ts b/src/pricing/median.ts new file mode 100644 index 00000000..683ae61f --- /dev/null +++ b/src/pricing/median.ts @@ -0,0 +1,197 @@ +import { pgPool } from '../db' +import { activeNetwork, NetworkName } from '../config' + +export interface PriceSourceEntry { + source: string + price: number + timestamp: Date | string + poolId?: string | null +} + +export interface ExcludedSourceEntry { + source: string + price: number + timestamp: string + reason: 'stale' | 'invalid' | 'outlier' + ageSeconds: number +} + +export interface StalenessAwareMedianResult { + pair: string + network: string + medianPrice: number | null + sourceCount: number + freshnessThresholdSeconds: number + sources: { + included: Array<{ source: string; price: number; timestamp: string; ageSeconds?: number }> + excluded: ExcludedSourceEntry[] + } + fallbackUsed: boolean + fallbackSource?: string + fallbackAgeSeconds?: number + reason: 'no_price_data' | 'all_sources_invalid' | null + calculatedAt: string +} + +/** + * Calculates the mathematical median of an array of numeric values. + * Returns null if the values array is empty. + */ +export function calculateMedian(values: number[]): number | null { + if (values.length === 0) return null + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] +} + +/** + * Computes staleness-aware median pricing across input price sources. + * Sources older than `freshnessThresholdSec` are dropped. + * When too few fresh sources remain, falls back through a priority chain. + */ +export function computeStalenessAwareMedian( + pairKey: string, + sources: PriceSourceEntry[], + options: { + network?: NetworkName + freshnessThresholdSec?: number + now?: number + fallbackPriority?: string[] + } = {} +): StalenessAwareMedianResult { + const network = options.network ?? activeNetwork + const freshnessThresholdSec = Math.max(5, Math.min(86400, options.freshnessThresholdSec ?? 300)) // bounded 5s to 24h + const now = options.now ?? Date.now() + const fallbackPriority = options.fallbackPriority ?? ['SDEX', 'AMM', 'SOROSWAP', 'PHOENIX', 'AQUARIUS', 'CEX'] + + const included: Array<{ source: string; price: number; timestamp: string; ageSeconds?: number }> = [] + const excluded: ExcludedSourceEntry[] = [] + + for (const entry of sources) { + const ts = new Date(entry.timestamp).getTime() + const ageSeconds = Math.max(0, Math.floor((now - ts) / 1000)) + const isoTimestamp = new Date(entry.timestamp).toISOString() + + if (!Number.isFinite(entry.price) || entry.price <= 0) { + excluded.push({ + source: entry.source, + price: entry.price, + timestamp: isoTimestamp, + reason: 'invalid', + ageSeconds, + }) + continue + } + + if (ageSeconds > freshnessThresholdSec) { + excluded.push({ + source: entry.source, + price: entry.price, + timestamp: isoTimestamp, + reason: 'stale', + ageSeconds, + }) + } else { + included.push({ + source: entry.source, + price: entry.price, + timestamp: isoTimestamp, + ageSeconds, + }) + } + } + + let medianPrice: number | null = null + let fallbackUsed = false + let fallbackSource: string | undefined + let fallbackAgeSeconds: number | undefined + let reason: 'no_price_data' | 'all_sources_invalid' | null = null + + if (included.length > 0) { + medianPrice = calculateMedian(included.map((s) => s.price)) + } else if (sources.length > 0) { + // Priority fallback: select highest priority source available even if aged + const validSources = sources.filter((s) => Number.isFinite(s.price) && s.price > 0) + + let chosen: PriceSourceEntry | undefined + for (const prio of fallbackPriority) { + chosen = validSources.find((s) => s.source.toUpperCase() === prio.toUpperCase()) + if (chosen) break + } + + if (!chosen && validSources.length > 0) { + // Pick the most recent valid source + chosen = [...validSources].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + )[0] + } + + if (chosen) { + fallbackUsed = true + medianPrice = chosen.price + fallbackSource = chosen.source + const chosenAgeSeconds = Math.max(0, Math.floor((now - new Date(chosen.timestamp).getTime()) / 1000)) + fallbackAgeSeconds = chosenAgeSeconds + included.push({ + source: chosen.source, + price: chosen.price, + timestamp: new Date(chosen.timestamp).toISOString(), + ageSeconds: chosenAgeSeconds, + }) + } else { + reason = 'all_sources_invalid' + } + } else { + reason = 'no_price_data' + } + + return { + pair: pairKey, + network, + medianPrice, + sourceCount: included.length, + freshnessThresholdSeconds: freshnessThresholdSec, + sources: { + included, + excluded, + }, + fallbackUsed, + fallbackSource, + fallbackAgeSeconds, + reason, + calculatedAt: new Date(now).toISOString(), + } +} + +/** + * Reads latest price points per source from the database and computes + * staleness-aware median pricing. + */ +export async function getStalenessAwareMedianPrice( + pairKey: string, + network: NetworkName = activeNetwork, + freshnessThresholdSec: number = 300 +): Promise { + const result = await pgPool.query( + `SELECT DISTINCT ON (source) source, price, timestamp, pool_id + FROM price_points + WHERE pair_key = $1 AND network = $2 + ORDER BY source, timestamp DESC`, + [pairKey, network] + ) + + const sources: PriceSourceEntry[] = result.rows.map((row: any) => ({ + source: row.source, + price: parseFloat(row.price), + timestamp: row.timestamp, + poolId: row.pool_id, + })) + + return computeStalenessAwareMedian(pairKey, sources, { + network, + freshnessThresholdSec, + }) +}