Skip to content
Open
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
183 changes: 128 additions & 55 deletions src/pricing/depth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,40 @@ export interface DepthLevel {
size: number
}

export interface ExcludedPool {
poolId: string
reason: string
}

export interface DepthResult {
status: 'ok'
network: string
pairKey: string
spotPrice: number
executionPrice: number
slippagePct: number
asks: DepthLevel[]
bids: DepthLevel[]
source: 'AMM' | 'SDEX' | 'BOTH'
source: 'AMM'
pool: {
poolId: string
feeBp: number
feeReadAt: string
reservesObservedAt: string
}
excludedPools: ExcludedPool[]
}

/**
* Calculates output amount for constant product AMM with fees:
* dy = (y * dx * (1 - fee)) / (x + dx * (1 - fee))
*/
export interface DepthUnavailable {
status: 'unavailable'
network: string
pairKey: string
reason: string
excludedPools: ExcludedPool[]
}

export type DepthOutcome = DepthResult | DepthUnavailable

export function calculateAMMOutput(reserveA: number, reserveB: number, amount: number, feeBp: number): number {
const fee = 1 - feeBp / 10000
const effectiveInput = amount * fee
Expand Down Expand Up @@ -60,19 +81,19 @@ export function generateAMMDepthLevels(
reserveB: number,
feeBp: number,
numLevels: number = 10,
stepSize: number = 0.01
stepSize: number = 0.01,
): { asks: DepthLevel[]; bids: DepthLevel[] } {
const asks: DepthLevel[] = []
const bids: DepthLevel[] = []

// Generate asks (selling asset A for B) — price is B per A
// Asks: selling asset A for B. Price is B per A.
for (let i = 1; i <= numLevels; i++) {
const amountA = reserveA * stepSize * i
const price = calculateAMMPrice(reserveA, reserveB, amountA, feeBp)
asks.push({ price, size: amountA })
}

// Generate bids (buying asset A with B)
// Bids: buying asset A with B. Price is B paid per A received.
for (let i = 1; i <= numLevels; i++) {
const amountB = reserveB * stepSize * i
const fee = 1 - feeBp / 10000
Expand All @@ -85,65 +106,117 @@ export function generateAMMDepthLevels(
return { asks, bids }
}

export async function getAMMDepth(
pairKey: string,
amount: number,
network: NetworkName = activeNetwork
): Promise<DepthResult | null> {
// Always network-scoped. `network` defaults to `activeNetwork`, so there is
// no caller that can ask for an unscoped read — which is the point: these
// tables carry rows for every network the process indexes, and an unfiltered
// read mixed testnet reserves into a mainnet depth answer.
interface AmmPoolRow {
poolId: string
reserveA: number
reserveB: number
observedAt: Date
}

/** Latest reserve snapshot per AMM pool for the pair, on one network only. */
async function loadLatestAmmPools(network: string, pairKey: string): Promise<AmmPoolRow[]> {
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.network = $2 AND ps.pool_id IN (
`SELECT DISTINCT ON (ps.pool_id) ps.pool_id, ps.reserve_a, ps.reserve_b, ps.timestamp
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`,
[pairKey, network]
ORDER BY ps.pool_id, ps.timestamp DESC`,
[pairKey, network],
)
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 ?? '30', 10)

const spotPrice = calculateAMMSpotPrice(rA, rB)
const executionPrice = calculateAMMPrice(rA, rB, amount, feeBp)
const slippagePct = calculateSlippage(spotPrice, executionPrice)
const { asks, bids } = generateAMMDepthLevels(rA, rB, feeBp)

return {
spotPrice,
executionPrice,
slippagePct,
asks,
bids,
source: 'AMM',
}
return result.rows
.map((r: { pool_id: string; reserve_a: string; reserve_b: string; timestamp: Date }) => ({
poolId: r.pool_id,
reserveA: parseFloat(r.reserve_a),
reserveB: parseFloat(r.reserve_b),
observedAt: r.timestamp,
}))
.sort((a, b) => (a.poolId < b.poolId ? -1 : a.poolId > b.poolId ? 1 : 0))
}

/**
* Prices an AMM swap using each pool's own fee, read from the venue.
*
* Pools whose fee cannot be read, or whose reserves are empty, are excluded and
* listed with the reason. If no pool survives, the result is "unavailable" with
* no numbers, never zeros.
*/
export async function getDepth(
network: string,
pairKey: string,
amount: number,
network: NetworkName = activeNetwork
): Promise<DepthResult> {
const ammDepth = await getAMMDepth(pairKey, amount, network)

if (ammDepth) {
return ammDepth
feeOpts: PoolFeeOptions,
): Promise<DepthOutcome> {
if (!(amount > 0)) {
throw new RangeError('amount must be greater than 0')
}

const pools = await loadLatestAmmPools(network, pairKey)
const excludedPools: ExcludedPool[] = []

if (pools.length === 0) {
return {
status: 'unavailable',
network,
pairKey,
reason: 'no AMM pool snapshots for this pair on this network',
excludedPools,
}
}

let chosen: DepthResult | null = null

for (const pool of pools) {
const feeRes = await getPoolFee(network, pool.poolId, feeOpts)
if (!feeRes.ok) {
excludedPools.push({ poolId: pool.poolId, reason: feeRes.reason })
continue
}
if (!(pool.reserveA > 0 && pool.reserveB > 0)) {
excludedPools.push({ poolId: pool.poolId, reason: 'pool has no reserves in its latest snapshot' })
continue
}
if (chosen) continue // keep checking fees so every excluded pool is reported

const { feeBp, feeReadAt } = feeRes.fee
const spotPrice = calculateAMMSpotPrice(pool.reserveA, pool.reserveB)
const executionPrice = calculateAMMPrice(pool.reserveA, pool.reserveB, amount, feeBp)
const slippagePct =
spotPrice > 0 ? Math.abs(((executionPrice - spotPrice) / spotPrice) * 100) : 0
const { asks, bids } = generateAMMDepthLevels(pool.reserveA, pool.reserveB, feeBp)

chosen = {
status: 'ok',
network,
pairKey,
spotPrice,
executionPrice,
slippagePct,
asks,
bids,
source: 'AMM',
pool: {
poolId: pool.poolId,
feeBp,
feeReadAt,
reservesObservedAt: pool.observedAt.toISOString(),
},
excludedPools,
}
}

if (chosen) {
chosen.excludedPools = excludedPools
return chosen
}

return {
spotPrice: 0,
executionPrice: 0,
slippagePct: 0,
asks: [],
bids: [],
source: 'AMM',
status: 'unavailable',
network,
pairKey,
reason: 'every AMM pool for this pair was excluded',
excludedPools,
}
}
80 changes: 80 additions & 0 deletions src/pricing/pool-fee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export interface PoolFee {
poolId: string
feeBp: number
/** ISO timestamp of when the venue was read. */
feeReadAt: string
}

export type PoolFeeResult =
| { ok: true; fee: PoolFee; cached: boolean }
| { ok: false; poolId: string; reason: string }

export interface PoolFeeOptions {
/** Horizon base URL for the network being answered, e.g. https://horizon-testnet.stellar.org */
horizonUrl: string
/** Cache lifetime in milliseconds. Placeholder default; confirm before merge. */
ttlMs?: number
/** Injectable clock for tests. */
now?: () => number
/** Injectable fetch for tests. */
fetchImpl?: typeof fetch
}

const DEFAULT_TTL_MS = 10 * 60 * 1000

interface CacheEntry {
fee: PoolFee
expiresAt: number
}

// Keyed by network and pool, so testnet and mainnet never share a fee.
const cache = new Map<string, CacheEntry>()

export function clearPoolFeeCache(): void {
cache.clear()
}

/**
* Reads a pool's fee from the venue (Horizon), with a TTL cache.
*
* Failures are never cached, so a transient outage is retried on the next call.
* A caller that receives { ok: false } must exclude the pool from execution
* estimates and report the reason. It must not fall back to a default fee.
*/
export async function getPoolFee(
network: string,
poolId: string,
opts: PoolFeeOptions,
): Promise<PoolFeeResult> {
const now = opts.now ?? Date.now
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS
const key = `${network}:${poolId}`

const hit = cache.get(key)
if (hit && hit.expiresAt > now()) {
return { ok: true, fee: hit.fee, cached: true }
}

const fetchImpl = opts.fetchImpl ?? fetch
let body: unknown
try {
const res = await fetchImpl(
`${opts.horizonUrl}/liquidity_pools/${encodeURIComponent(poolId)}`,
)
if (!res.ok) {
return { ok: false, poolId, reason: `venue responded ${res.status}` }
}
body = await res.json()
} catch (err) {
return { ok: false, poolId, reason: `venue unreachable: ${(err as Error).message}` }
}

const feeBp = (body as { fee_bp?: unknown } | null)?.fee_bp
if (typeof feeBp !== 'number' || !Number.isInteger(feeBp) || feeBp < 0 || feeBp >= 10000) {
return { ok: false, poolId, reason: 'venue did not return a valid fee_bp' }
}

const fee: PoolFee = { poolId, feeBp, feeReadAt: new Date(now()).toISOString() }
cache.set(key, { fee, expiresAt: now() + ttl })
return { ok: true, fee, cached: false }
}
Loading