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
142 changes: 104 additions & 38 deletions src/pricing/depth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,28 @@ 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[]
}

export interface ImpactPoint {
Expand Down Expand Up @@ -180,19 +195,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 Down Expand Up @@ -222,49 +237,100 @@ export async function getAMMDepth(
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)
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 (ammDepth) {
return ammDepth
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