diff --git a/src/__tests__/schemaValidation.test.ts b/src/__tests__/schemaValidation.test.ts index 55f96239..52e508f9 100644 --- a/src/__tests__/schemaValidation.test.ts +++ b/src/__tests__/schemaValidation.test.ts @@ -24,6 +24,10 @@ vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +vi.mock('../aggregator/splitRoute', () => ({ + getSplitRecommendation: mockGetSplitRecommendation, +})) + vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mockGetAggregatedPrice, })) @@ -55,7 +59,7 @@ vi.mock('../config', () => ({ })) import { registerRESTRoutes } from '../api/rest' -import { priceResponseSchema } from '../api/schemas' +import { priceResponseSchema, splitResponseSchema } from '../api/schemas' async function buildApp() { const app = Fastify({ logger: false }) diff --git a/src/__tests__/splitRoute.test.ts b/src/__tests__/splitRoute.test.ts new file mode 100644 index 00000000..12e4fa7d --- /dev/null +++ b/src/__tests__/splitRoute.test.ts @@ -0,0 +1,210 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { getSplitRecommendation, fetchSDEXPrice, fetchAMMData } from '../aggregator/splitRoute' +import { _resetHorizonServers } from '../aggregator/bestRoute' +import { pgPool } from '../db' +import * as StellarSdk from '@stellar/stellar-sdk' + +// Mock database +vi.mock('../db', () => ({ + pgPool: { + query: vi.fn(), + }, +})) + +let currentStrictSendAmount: number = 1000 +const mockCallFn = vi.fn() + +vi.mock('@stellar/stellar-sdk', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Horizon: { + Server: vi.fn(function () { + return { + strictSendPaths: vi.fn(function (_from, amountStr) { + currentStrictSendAmount = parseFloat(amountStr) + return { + call: () => mockCallFn(currentStrictSendAmount), + } + }), + } + }), + }, + Asset: Object.assign( + vi.fn(function (code, issuer) { + return { code, issuer } + }), + { native: vi.fn(() => 'native') } + ), + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + __mockCall: mockCallFn, + } +}) + +describe('getSplitRecommendation', () => { + const assetA = { code: 'USDC', issuer: 'GA2C5RFPE6GCKIG3EQNCIMHO7OA7Q6M2XQ2UBNR5NCTU24VTY4A2J7B2' } + const assetB = { code: 'XLM', issuer: null } + const pairKey = 'USDC:GA2C5RFPE6GCKIG3EQNCIMHO7OA7Q6M2XQ2UBNR5NCTU24VTY4A2J7B2/XLM' + + const mockQuery = vi.mocked(pgPool.query) + + beforeEach(() => { + vi.clearAllMocks() + _resetHorizonServers() + }) + + // Fixed pool: 10,000 A / 10,000 B, 30 bp fee, spot = 1.0 + const standardPool = { + rows: [ + { + reserve_a: '10000', + reserve_b: '10000', + fee_bp: '30', + timestamp: new Date('2026-10-16T12:00:00Z'), + pool_id: 'pool-usdc-xlm-1', + }, + ], + } as any + + it('Case 1: returns split recommendation and proportions when splitting beats single-venue route', async () => { + mockQuery.mockResolvedValue(standardPool) + + // Realistic order book: deep top-of-book at 0.98 for first 2000 units, then drops to 0.75 + mockCallFn.mockImplementation((amount: number) => { + let dest: number + if (amount <= 2000) { + dest = amount * 0.98 + } else { + dest = 2000 * 0.98 + (amount - 2000) * 0.75 + } + return Promise.resolve({ + records: [{ destination_amount: dest.toString() }], + }) + }) + + const amount = 6000 + const result = await getSplitRecommendation(assetA, assetB, pairKey, amount, 'testnet') + + // Acceptance criterion 1 & 2: Proportions per venue returned when splitting beats single venue + expect(result.splitRecommended).toBe(true) + expect(result.proportions).toBeDefined() + expect(result.proportions?.SDEX).toBeGreaterThan(0) + expect(result.proportions?.AMM).toBeGreaterThan(0) + expect(result.proportions!.SDEX + result.proportions!.AMM).toBeCloseTo(1.0, 3) + + // Acceptance criterion 3: Comparison is in the response + expect(result.comparison).toBeDefined() + expect(result.comparison?.singleVenueRoute).toBe('SDEX') + expect(result.comparison!.singleVenueOutput).toBeCloseTo(4960, 1) + expect(result.comparison!.outputImprovement).toBeGreaterThan(0) + expect(result.comparison!.outputImprovementPct).toBeGreaterThan(0) + expect(result.estimatedOutput).toBeGreaterThan(result.comparison!.singleVenueOutput) + + // Allocations detail + expect(result.allocations).toHaveLength(2) + const sdexAlloc = result.allocations?.find((a) => a.venue === 'SDEX') + const ammAlloc = result.allocations?.find((a) => a.venue === 'AMM') + expect(sdexAlloc).toBeDefined() + expect(ammAlloc).toBeDefined() + expect(sdexAlloc!.amount + ammAlloc!.amount).toBeCloseTo(amount, 2) + + // Provenance requirement: numbers carry provenance + expect(result.provenance.venues).toEqual(['SDEX', 'AMM']) + expect(result.provenance.samples).toBeGreaterThan(1) + expect(result.provenance.asOf).toBeDefined() + + // Network requirement: echoed in response + expect(result.network).toBe('testnet') + }) + + it('Case 2: does NOT return split proportions when single-venue route beats splitting', async () => { + mockQuery.mockResolvedValue(standardPool) + + // For a small order of 1000 units, SDEX fills entirely at top-of-book (0.98), beating AMM + mockCallFn.mockImplementation((amount: number) => { + let dest: number + if (amount <= 2000) { + dest = amount * 0.98 + } else { + dest = 2000 * 0.98 + (amount - 2000) * 0.75 + } + return Promise.resolve({ + records: [{ destination_amount: dest.toString() }], + }) + }) + + const amount = 1000 + const result = await getSplitRecommendation(assetA, assetB, pairKey, amount, 'testnet') + + // Acceptance criterion 2: Only returned when it beats the single-venue route + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.allocations).toBeUndefined() + expect(result.reason).toBe('single_venue_better') + + // Acceptance criterion 3: Comparison in the response showing single-venue winner + expect(result.comparison).toBeDefined() + expect(result.comparison?.singleVenueRoute).toBe('SDEX') + expect(result.comparison?.outputImprovement).toBe(0) + expect(result.singleVenue?.route).toBe('SDEX') + expect(result.singleVenue?.output).toBeCloseTo(980, 1) + }) + + it('Case 3: honest answer when pair only has AMM liquidity', async () => { + mockQuery.mockResolvedValue(standardPool) + mockCallFn.mockResolvedValue({ records: [] }) // SDEX has no paths + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'testnet') + + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.reason).toBe('single_venue_only') + expect(result.message).toContain('Only AMM liquidity available') + expect(result.singleVenue?.route).toBe('AMM') + expect(result.provenance.venues).toEqual(['AMM']) + }) + + it('Case 4: honest answer when pair only has SDEX liquidity', async () => { + mockQuery.mockResolvedValue({ rows: [] } as any) // No pool snapshot + mockCallFn.mockResolvedValue({ + records: [{ destination_amount: '500' }], + }) + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'testnet') + + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.reason).toBe('single_venue_only') + expect(result.message).toContain('Only SDEX liquidity available') + expect(result.singleVenue?.route).toBe('SDEX') + expect(result.provenance.venues).toEqual(['SDEX']) + }) + + it('Case 5: honest answer when neither venue has liquidity (not 0, not 500)', async () => { + mockQuery.mockResolvedValue({ rows: [] } as any) + mockCallFn.mockResolvedValue({ records: [] }) + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'testnet') + + expect(result.splitRecommended).toBe(false) + expect(result.proportions).toBeUndefined() + expect(result.reason).toBe('no_liquidity') + expect(result.error).toContain('No liquidity available') + expect(result.provenance.venues).toEqual([]) + }) + + it('Case 6: scopes database query and echo to requested network', async () => { + mockQuery.mockResolvedValue(standardPool) + mockCallFn.mockResolvedValue({ records: [{ destination_amount: '500' }] }) + + const result = await getSplitRecommendation(assetA, assetB, pairKey, 1000, 'mainnet') + + expect(result.network).toBe('mainnet') + const [sql, params] = mockQuery.mock.calls[0] + expect(sql).toContain('ps.network = $2') + expect(params).toEqual([pairKey, 'mainnet']) + }) +}) diff --git a/src/aggregator/bestRoute.ts b/src/aggregator/bestRoute.ts index fe2cb44a..b4b3ff05 100644 --- a/src/aggregator/bestRoute.ts +++ b/src/aggregator/bestRoute.ts @@ -4,6 +4,7 @@ import { getHorizonServer, resetNetworkClients } from '../network/clients' import type { AssetId, RouteInfo, RouteHop, RouteOption, RouteProvenance } from '../types' import { pgPool } from '../db' import { calculateAMMSpotPrice } from '../pricing/depth' +export { getSplitRecommendation } from './splitRoute' function assetIdToStellar(asset: AssetId) { if (!asset.issuer) return Asset.native() diff --git a/src/aggregator/splitRoute.ts b/src/aggregator/splitRoute.ts new file mode 100644 index 00000000..5f0c72d0 --- /dev/null +++ b/src/aggregator/splitRoute.ts @@ -0,0 +1,351 @@ +import { Asset } from '@stellar/stellar-sdk' +import { activeNetwork, type NetworkName } from '../config' +import { getHorizonServer } from '../network/clients' +import type { + AssetId, + SplitRecommendation, + SplitVenueAllocation, + SplitComparison, + SplitProvenance, +} from '../types' +import { pgPool } from '../db' +import { calculateAMMSpotPrice, calculateAMMOutput } from '../pricing/depth' + +function assetIdToStellar(asset: AssetId) { + if (!asset.issuer) return Asset.native() + return new Asset(asset.code, asset.issuer) +} + +export async function fetchSDEXPrice( + assetA: AssetId, + assetB: AssetId, + amount: number, + network: NetworkName +): Promise { + if (amount <= 0) return 0 + try { + const stellarAssetA = assetIdToStellar(assetA) + const stellarAssetB = assetIdToStellar(assetB) + const paths = await getHorizonServer(network) + .strictSendPaths(stellarAssetA, amount.toString(), [stellarAssetB]) + .call() + if (paths.records.length === 0) return 0 + const best = paths.records[0] + return parseFloat(best.destination_amount) / amount + } catch { + return 0 + } +} + +interface AMMData { + rA: number + rB: number + feeBp: number + spotPrice: number + timestamp: Date | null +} + +export async function fetchAMMData( + pairKey: string, + network: NetworkName +): Promise { + const result = await pgPool.query( + `SELECT DISTINCT ON (ps.pool_id) ps.reserve_a, ps.reserve_b, ps.fee_bp, 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] + ) + + if (!result.rows[0]) return null + + const { reserve_a, reserve_b, fee_bp, timestamp } = result.rows[0] + const rA = parseFloat(reserve_a) + const rB = parseFloat(reserve_b) + const feeBp = parseInt(fee_bp, 10) + const spotPrice = calculateAMMSpotPrice(rA, rB) + + return { + rA, + rB, + feeBp, + spotPrice, + timestamp: timestamp ? new Date(timestamp) : null, + } +} + +/** + * Computes optimal split proportions across SDEX and AMM venues for a given swap size. + * + * Returned with split proportions only when splitting actually yields higher output + * (lower total slippage) than the single-venue alternative. + */ +export async function getSplitRecommendation( + assetA: AssetId, + assetB: AssetId, + pairKey: string, + amount: number = 1000, + network: NetworkName = activeNetwork +): Promise { + const now = new Date() + + // 1. Fetch available venue liquidity in parallel + const [sdexFullPrice, amm] = await Promise.all([ + fetchSDEXPrice(assetA, assetB, amount, network), + fetchAMMData(pairKey, network), + ]) + + const hasAmm = amm !== null && amm.rA > 0 && amm.rB > 0 + const hasSdex = sdexFullPrice > 0 + + const poolFreshnessSeconds = amm?.timestamp + ? Math.max(0, Math.round((now.getTime() - amm.timestamp.getTime()) / 1000)) + : null + + // Case A: Neither venue has liquidity — honest answer, not 0 and not 500 + if (!hasAmm && !hasSdex) { + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'no_liquidity', + message: `No liquidity available for pair ${assetA.code}/${assetB.code} on ${network}`, + error: `No liquidity available for pair ${assetA.code}/${assetB.code} on ${network}`, + provenance: { + venues: [], + samples: 0, + asOf: now.toISOString(), + freshnessSeconds: null, + }, + } + } + + // Case B: Only SDEX available + if (!hasAmm && hasSdex) { + const singleVenueOutput = amount * sdexFullPrice + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'single_venue_only', + message: 'Only SDEX liquidity available — splitting across venues is not possible', + singleVenue: { + route: 'SDEX', + output: singleVenueOutput, + price: sdexFullPrice, + slippagePct: 0, + }, + provenance: { + venues: ['SDEX'], + samples: 1, + asOf: now.toISOString(), + freshnessSeconds: 0, + }, + } + } + + // Case C: Only AMM available + if (hasAmm && !hasSdex) { + const ammFullOutput = calculateAMMOutput(amm.rA, amm.rB, amount, amm.feeBp) + const ammFullPrice = ammFullOutput / amount + const ammFullSlippage = + amm.spotPrice > 0 + ? Math.max(0, ((amm.spotPrice - ammFullPrice) / amm.spotPrice) * 100) + : 0 + + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'single_venue_only', + message: 'Only AMM liquidity available — splitting across venues is not possible', + singleVenue: { + route: 'AMM', + output: ammFullOutput, + price: ammFullPrice, + slippagePct: ammFullSlippage, + }, + provenance: { + venues: ['AMM'], + samples: 1, + asOf: amm.timestamp ? amm.timestamp.toISOString() : now.toISOString(), + freshnessSeconds: poolFreshnessSeconds, + }, + } + } + + // Case D: Both venues have liquidity + const ammFullOutput = calculateAMMOutput(amm!.rA, amm!.rB, amount, amm!.feeBp) + const ammFullPrice = ammFullOutput / amount + const ammSpotPrice = amm!.spotPrice + + const sdexFullOutput = amount * sdexFullPrice + + const ammFullSlippage = + ammSpotPrice > 0 + ? Math.max(0, ((ammSpotPrice - ammFullPrice) / ammSpotPrice) * 100) + : 0 + const sdexFullSlippage = + ammSpotPrice > 0 + ? Math.max(0, ((ammSpotPrice - sdexFullPrice) / ammSpotPrice) * 100) + : 0 + + const winningSingleVenue: 'SDEX' | 'AMM' = + sdexFullOutput >= ammFullOutput ? 'SDEX' : 'AMM' + const singleVenueOutput = Math.max(sdexFullOutput, ammFullOutput) + const singleVenuePrice = singleVenueOutput / amount + const singleVenueSlippage = + winningSingleVenue === 'SDEX' ? sdexFullSlippage : ammFullSlippage + + // Candidate proportions for SDEX (with the remaining portion directed to AMM) + const candidateFractions = [ + 0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, + 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95 + ] + + // Query SDEX quotes for candidate fractions in parallel + const candidateResults = await Promise.all( + candidateFractions.map(async (pSdex) => { + const aSdex = amount * pSdex + const aAmm = amount - aSdex + const [priceSdex] = await Promise.all([ + fetchSDEXPrice(assetA, assetB, aSdex, network), + ]) + const outSdex = aSdex * priceSdex + const outAmm = calculateAMMOutput(amm!.rA, amm!.rB, aAmm, amm!.feeBp) + const totalOutput = outSdex + outAmm + return { + pSdex, + pAmm: 1 - pSdex, + aSdex, + aAmm, + priceSdex, + outSdex, + outAmm, + totalOutput, + } + }) + ) + + let best = candidateResults[0] + for (const c of candidateResults) { + if (c.totalOutput > best.totalOutput) { + best = c + } + } + + // Splitting is recommended ONLY when it strictly beats the best single-venue route + const improvement = best.totalOutput - singleVenueOutput + const improvementPct = (improvement / singleVenueOutput) * 100 + + // Require measurable improvement (> 0.01% or > 1e-6 units) + const splittingWins = improvement > 1e-6 && improvementPct > 0.01 + + const provenance: SplitProvenance = { + venues: ['SDEX', 'AMM'], + samples: candidateFractions.length + 1, + asOf: now.toISOString(), + freshnessSeconds: poolFreshnessSeconds, + } + + if (!splittingWins) { + // When splitting does not beat the single-venue route, proportions are NOT returned + return { + splitRecommended: false, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + reason: 'single_venue_better', + message: `Routing entirely to ${winningSingleVenue} yields higher output than splitting across venues`, + singleVenue: { + route: winningSingleVenue, + output: singleVenueOutput, + price: singleVenuePrice, + slippagePct: singleVenueSlippage, + }, + comparison: { + singleVenueRoute: winningSingleVenue, + singleVenueOutput, + singleVenueEffectivePrice: singleVenuePrice, + singleVenueSlippagePct: singleVenueSlippage, + outputImprovement: 0, + outputImprovementPct: 0, + }, + provenance, + } + } + + const effectivePrice = best.totalOutput / amount + const splitSlippagePct = + ammSpotPrice > 0 + ? Math.max(0, ((ammSpotPrice - effectivePrice) / ammSpotPrice) * 100) + : 0 + + const allocations: SplitVenueAllocation[] = [ + { + venue: 'SDEX', + proportion: parseFloat(best.pSdex.toFixed(4)), + amount: parseFloat(best.aSdex.toFixed(7)), + estimatedOutput: parseFloat(best.outSdex.toFixed(7)), + effectivePrice: parseFloat((best.outSdex / best.aSdex).toFixed(7)), + }, + { + venue: 'AMM', + proportion: parseFloat(best.pAmm.toFixed(4)), + amount: parseFloat(best.aAmm.toFixed(7)), + estimatedOutput: parseFloat(best.outAmm.toFixed(7)), + effectivePrice: parseFloat((best.outAmm / best.aAmm).toFixed(7)), + }, + ] + + const comparison: SplitComparison = { + singleVenueRoute: winningSingleVenue, + singleVenueOutput: parseFloat(singleVenueOutput.toFixed(7)), + singleVenueEffectivePrice: parseFloat(singleVenuePrice.toFixed(7)), + singleVenueSlippagePct: parseFloat(singleVenueSlippage.toFixed(4)), + outputImprovement: parseFloat(improvement.toFixed(7)), + outputImprovementPct: parseFloat(improvementPct.toFixed(4)), + } + + return { + splitRecommended: true, + assetA: assetA.code, + assetB: assetB.code, + pairKey, + network, + amount, + message: `Splitting across SDEX and AMM yields ${improvementPct.toFixed(2)}% better output than single-venue route (${winningSingleVenue})`, + proportions: { + SDEX: parseFloat(best.pSdex.toFixed(4)), + AMM: parseFloat(best.pAmm.toFixed(4)), + }, + allocations, + estimatedOutput: parseFloat(best.totalOutput.toFixed(7)), + effectivePrice: parseFloat(effectivePrice.toFixed(7)), + slippagePct: parseFloat(splitSlippagePct.toFixed(4)), + singleVenue: { + route: winningSingleVenue, + output: parseFloat(singleVenueOutput.toFixed(7)), + price: parseFloat(singleVenuePrice.toFixed(7)), + slippagePct: parseFloat(singleVenueSlippage.toFixed(4)), + }, + comparison, + provenance, + } +} diff --git a/src/alerts/index.ts b/src/alerts/index.ts index 92bfbe30..0177af40 100644 --- a/src/alerts/index.ts +++ b/src/alerts/index.ts @@ -1,4 +1,4 @@ export * from './delivery' export * from './threshold' export * from './arbitrage' -export * from './dispersion' \ No newline at end of file +export * from './dispersion' diff --git a/src/alerts/pegDeviation.ts b/src/alerts/pegDeviation.ts new file mode 100644 index 00000000..c0a885d9 --- /dev/null +++ b/src/alerts/pegDeviation.ts @@ -0,0 +1,432 @@ +import { deliverJsonWithRetries } from './delivery' + +export type PegAlertEventType = 'deviation' | 'recovery' +export type PegDeviationDirection = 'above' | 'below' +export type PegMonitorStatus = 'pegged' | 'deviating' | 'depegged' | 'recovering' + +export interface PegDeviationConfig { + asset: string + targetAsset?: string // default: 'USD' + targetPrice?: number // default: 1.0 + band?: number // deviation fraction, e.g. 0.01 for 1% + bandBps?: number // deviation in basis points, e.g. 100 for 1% + bandPct?: number // deviation in percentage fraction, e.g. 0.01 for 1% + sustainedDurationMs: number // minimum sustained duration in ms before firing + recoveryDurationMs?: number // minimum sustained duration in ms before firing recovery (default: 0) + minSamples?: number // minimum samples required during sustained window (default: 2) + network?: string + url?: string + secret?: string +} + +export interface PegPriceObservation { + price: number + venue?: string + venues?: string[] + timestamp?: number | string | Date + network?: string +} + +export interface PegAlertProvenance { + venues: string[] + sampleCount: number + freshnessMs: number +} + +export interface PegDeviationAlertPayload { + eventType: 'deviation' + asset: string + targetAsset: string + price: number + targetPrice: number + deviationPct: number + deviationBps: number + band: number + direction: PegDeviationDirection + sustainedDurationMs: number + deviationStartedAt: string + timestamp: string + venues: string[] + sampleCount: number + network?: string + provenance: PegAlertProvenance +} + +export interface PegRecoveryAlertPayload { + eventType: 'recovery' + asset: string + targetAsset: string + price: number + targetPrice: number + deviationPct: number + deviationBps: number + band: number + deviationStartedAt: string + recoveredAt: string + durationMs: number + timestamp: string + venues: string[] + sampleCount: number + network?: string + provenance: PegAlertProvenance +} + +export type PegAlertPayload = PegDeviationAlertPayload | PegRecoveryAlertPayload + +export interface PegDeviationSubscription extends PegDeviationConfig { + id?: string +} + +function toTimestampMs(ts?: number | string | Date): number { + if (ts === undefined || ts === null) return Date.now() + if (typeof ts === 'number') return ts + if (ts instanceof Date) return ts.getTime() + const parsed = new Date(ts).getTime() + return isNaN(parsed) ? Date.now() : parsed +} + +function extractVenues(obs: PegPriceObservation): string[] { + if (obs.venues && Array.isArray(obs.venues)) { + return obs.venues.filter(v => typeof v === 'string' && v.length > 0) + } + if (obs.venue && typeof obs.venue === 'string' && obs.venue.length > 0) { + return [obs.venue] + } + return [] +} + +export function buildPegDeviationAlertPayload( + config: PegDeviationConfig, + currentPrice: number, + deviationStartedAt: number, + timestamp: number, + venues: string[], + sampleCount: number +): PegDeviationAlertPayload { + const targetPrice = config.targetPrice ?? 1.0 + const band = resolveBand(config) + const deviationPct = (currentPrice - targetPrice) / targetPrice + const deviationBps = Math.round(deviationPct * 10000) + const direction: PegDeviationDirection = currentPrice >= targetPrice ? 'above' : 'below' + const freshnessMs = Math.max(0, Date.now() - timestamp) + + return { + eventType: 'deviation', + asset: config.asset.toUpperCase(), + targetAsset: (config.targetAsset ?? 'USD').toUpperCase(), + price: currentPrice, + targetPrice, + deviationPct, + deviationBps, + band, + direction, + sustainedDurationMs: config.sustainedDurationMs, + deviationStartedAt: new Date(deviationStartedAt).toISOString(), + timestamp: new Date(timestamp).toISOString(), + venues: [...venues], + sampleCount, + network: config.network, + provenance: { + venues: [...venues], + sampleCount, + freshnessMs, + }, + } +} + +export function buildPegRecoveryAlertPayload( + config: PegDeviationConfig, + currentPrice: number, + deviationStartedAt: number, + recoveredAt: number, + venues: string[], + sampleCount: number +): PegRecoveryAlertPayload { + const targetPrice = config.targetPrice ?? 1.0 + const band = resolveBand(config) + const deviationPct = (currentPrice - targetPrice) / targetPrice + const deviationBps = Math.round(deviationPct * 10000) + const durationMs = Math.max(0, recoveredAt - deviationStartedAt) + const freshnessMs = Math.max(0, Date.now() - recoveredAt) + + return { + eventType: 'recovery', + asset: config.asset.toUpperCase(), + targetAsset: (config.targetAsset ?? 'USD').toUpperCase(), + price: currentPrice, + targetPrice, + deviationPct, + deviationBps, + band, + deviationStartedAt: new Date(deviationStartedAt).toISOString(), + recoveredAt: new Date(recoveredAt).toISOString(), + durationMs, + timestamp: new Date(recoveredAt).toISOString(), + venues: [...venues], + sampleCount, + network: config.network, + provenance: { + venues: [...venues], + sampleCount, + freshnessMs, + }, + } +} + +function resolveBand(config: PegDeviationConfig): number { + if (config.bandBps !== undefined) { + return config.bandBps / 10000 + } + if (config.bandPct !== undefined) { + return config.bandPct + } + if (config.band !== undefined) { + return config.band + } + return 0.01 // default 1% band +} + +export async function deliverPegAlert( + destination: { url: string; secret: string }, + payload: PegAlertPayload +): Promise { + return deliverJsonWithRetries(destination.url, payload, destination.secret) +} + +export class PegDeviationTracker { + private config: PegDeviationConfig + private onAlert?: (payload: PegAlertPayload) => void + + private status: PegMonitorStatus = 'pegged' + private deviationStartTime: number | null = null + private deviationTicks: PegPriceObservation[] = [] + private observedVenues = new Set() + + // For sustained recovery tracking if recoveryDurationMs > 0 + private recoveryStartTime: number | null = null + private recoveryTicks: PegPriceObservation[] = [] + private recoveryVenues = new Set() + + constructor( + config: PegDeviationConfig, + onAlert?: (payload: PegAlertPayload) => void + ) { + const targetPrice = config.targetPrice ?? 1.0 + if (targetPrice <= 0 || !Number.isFinite(targetPrice)) { + throw new Error(`targetPrice must be a positive finite number, got: ${config.targetPrice}`) + } + + const band = resolveBand(config) + if (band <= 0 || !Number.isFinite(band)) { + throw new Error(`band must be a positive finite number, got: ${band}`) + } + + if (config.sustainedDurationMs < 0 || !Number.isFinite(config.sustainedDurationMs)) { + throw new Error(`sustainedDurationMs must be a non-negative number, got: ${config.sustainedDurationMs}`) + } + + this.config = { + ...config, + targetPrice, + band, + targetAsset: config.targetAsset ?? 'USD', + minSamples: config.minSamples ?? 2, + recoveryDurationMs: config.recoveryDurationMs ?? 0, + } + this.onAlert = onAlert + } + + public getStatus(): PegMonitorStatus { + return this.status + } + + public reset(): void { + this.status = 'pegged' + this.deviationStartTime = null + this.deviationTicks = [] + this.observedVenues.clear() + this.recoveryStartTime = null + this.recoveryTicks = [] + this.recoveryVenues.clear() + } + + /** + * Process a price observation. + * + * Rejects non-finite or non-positive prices ("zero is a number people act on"). + * Returns an alert payload if an alert (deviation or recovery) fired on this tick, or null otherwise. + */ + public recordTick(observation: PegPriceObservation): PegAlertPayload | null { + const { price } = observation + if (price === null || price === undefined || !Number.isFinite(price) || price <= 0) { + return null + } + + const targetPrice = this.config.targetPrice ?? 1.0 + const band = resolveBand(this.config) + const tickTime = toTimestampMs(observation.timestamp) + const venues = extractVenues(observation) + + const absDeviation = Math.abs(price - targetPrice) / targetPrice + const isDeviated = absDeviation > band + + if (isDeviated) { + // Devation observed + this.recoveryStartTime = null + this.recoveryTicks = [] + this.recoveryVenues.clear() + + venues.forEach(v => this.observedVenues.add(v)) + this.deviationTicks.push(observation) + + if (this.status === 'pegged') { + this.status = 'deviating' + this.deviationStartTime = tickTime + + // Check if single tick can fire (only if sustainedDurationMs == 0 and minSamples <= 1) + const minSamples = this.config.minSamples ?? 2 + const elapsed = tickTime - this.deviationStartTime + if (elapsed >= this.config.sustainedDurationMs && this.deviationTicks.length >= minSamples) { + return this.fireDeviationAlert(price, tickTime) + } + return null + } + + if (this.status === 'deviating') { + const minSamples = this.config.minSamples ?? 2 + const startTime = this.deviationStartTime ?? tickTime + const elapsed = tickTime - startTime + + if (elapsed >= this.config.sustainedDurationMs && this.deviationTicks.length >= minSamples) { + return this.fireDeviationAlert(price, tickTime) + } + return null + } + + if (this.status === 'recovering') { + // Price slipped back out of band during recovery period + this.status = 'depegged' + return null + } + + // Already 'depegged' — ongoing deviation + return null + } else { + // Price is WITHIN the band + if (this.status === 'deviating') { + // Was deviating, but before sustained duration / min samples were met, it returned to peg! + // This was a transient fluctuation or single bad tick outlier. + // It resets back to pegged without firing any alert! + this.reset() + return null + } + + if (this.status === 'pegged') { + // Normal steady state + return null + } + + if (this.status === 'depegged' || this.status === 'recovering') { + venues.forEach(v => this.recoveryVenues.add(v)) + this.recoveryTicks.push(observation) + + const recoveryDurationMs = this.config.recoveryDurationMs ?? 0 + if (recoveryDurationMs <= 0) { + // Immediate recovery + return this.fireRecoveryAlert(price, tickTime) + } + + if (this.status === 'depegged') { + this.status = 'recovering' + this.recoveryStartTime = tickTime + } + + const elapsedRecovery = tickTime - (this.recoveryStartTime ?? tickTime) + if (elapsedRecovery >= recoveryDurationMs) { + return this.fireRecoveryAlert(price, tickTime) + } + + return null + } + + return null + } + } + + private fireDeviationAlert(price: number, tickTime: number): PegDeviationAlertPayload { + this.status = 'depegged' + const startTime = this.deviationStartTime ?? tickTime + const venues = Array.from(this.observedVenues) + const payload = buildPegDeviationAlertPayload( + this.config, + price, + startTime, + tickTime, + venues.length > 0 ? venues : (this.config.network ? [this.config.network] : ['unknown']), + this.deviationTicks.length + ) + + this.dispatchPayload(payload) + return payload + } + + private fireRecoveryAlert(price: number, tickTime: number): PegRecoveryAlertPayload { + const startTime = this.deviationStartTime ?? tickTime + // Combined venues across incident and recovery + const venuesSet = new Set([...this.observedVenues, ...this.recoveryVenues]) + const venues = Array.from(venuesSet) + + const payload = buildPegRecoveryAlertPayload( + this.config, + price, + startTime, + tickTime, + venues.length > 0 ? venues : (this.config.network ? [this.config.network] : ['unknown']), + this.deviationTicks.length + this.recoveryTicks.length + ) + + this.reset() + this.dispatchPayload(payload) + return payload + } + + private dispatchPayload(payload: PegAlertPayload): void { + if (this.onAlert) { + try { + this.onAlert(payload) + } catch (err) { + console.error('[PegDeviationTracker] onAlert callback failed:', (err as Error).message) + } + } + + if (this.config.url && this.config.secret) { + deliverPegAlert( + { url: this.config.url, secret: this.config.secret }, + payload + ).catch(err => { + console.error(`[PegDeviationTracker] Webhook delivery failed to ${this.config.url}:`, (err as Error).message) + }) + } + } +} + +/** + * Functional convenience wrapper to check peg deviation over a sequence of observations. + */ +export function checkPegDeviation( + observations: PegPriceObservation[], + config: PegDeviationConfig, + onAlert?: (payload: PegAlertPayload) => void +): PegAlertPayload[] { + const tracker = new PegDeviationTracker(config, onAlert) + const triggered: PegAlertPayload[] = [] + + for (const obs of observations) { + const alert = tracker.recordTick(obs) + if (alert) { + triggered.push(alert) + } + } + + return triggered +} diff --git a/src/api/rest.ts b/src/api/rest.ts index 0c7c0b43..c7f440d5 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -7,6 +7,7 @@ import { price_requests_total } from '../metrics' import { getCachedPrice, setCachedPrice } from '../redis' import { getAggregatedPrice } from '../aggregator/vwap' import { getBestRoute } from '../aggregator/bestRoute' +import { getSplitRecommendation } from '../aggregator/splitRoute' import { pgPool } from '../db' import { config, getNetworkConfig, activeNetwork, type NetworkName } from '../config' import { isAdminConfigured } from './auth' @@ -16,6 +17,7 @@ import { statusErrorResponseSchema, priceResponseSchema, routeResponseSchema, + splitResponseSchema, historyResponseSchema, poolsResponseSchema, depthResponseSchema, @@ -290,6 +292,29 @@ export async function registerRESTRoutes(app: FastifyInstance) { } ) + // GET /price/:assetA/:assetB/split?amount=1000 + app.get<{ + Params: { assetA: string; assetB: string } + Querystring: { amount?: string; network?: NetworkName } + }>( + '/price/:assetA/:assetB/split', + { schema: { response: { 200: splitResponseSchema } } }, + async (req, reply) => { + const { assetA, assetB } = req.params + const amount = parseFloat(req.query.amount ?? '1000') + 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}` }) + if (isNaN(amount) || amount <= 0) return reply.status(400).send({ error: 'amount must be a positive number' }) + + const result = await getSplitRecommendation(pair.assetA, pair.assetB, pair.pairKey, amount, network) + if (result.error) { + return reply.status(404).send({ error: result.error }) + } + return result + } + ) + // GET /price/:assetA/:assetB/history?window=1h&limit=100 app.get<{ Params: { assetA: string; assetB: string } diff --git a/tests/pegDeviation.test.ts b/tests/pegDeviation.test.ts new file mode 100644 index 00000000..f521e482 --- /dev/null +++ b/tests/pegDeviation.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + PegDeviationTracker, + checkPegDeviation, + buildPegDeviationAlertPayload, + buildPegRecoveryAlertPayload, + deliverPegAlert, + type PegPriceObservation, + type PegDeviationConfig, +} from '../src/alerts/pegDeviation' +import * as delivery from '../src/alerts/delivery' + +describe('Peg Deviation Alerts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Configurable band', () => { + it('uses configured band (e.g. 2%) and only deviates when outside that band', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + band: 0.02, // 2% band [0.98, 1.02] + sustainedDurationMs: 10_000, + minSamples: 2, + }, + alert + ) + + const baseTime = 1_000_000 + + // Price at 0.985 is within 2% band -> no deviation + tracker.recordTick({ price: 0.985, venue: 'sdex', timestamp: baseTime }) + tracker.recordTick({ price: 0.985, venue: 'sdex', timestamp: baseTime + 15_000 }) + expect(alert).not.toHaveBeenCalled() + expect(tracker.getStatus()).toBe('pegged') + + // Price at 0.975 is outside 2% band -> starts deviating + tracker.recordTick({ price: 0.975, venue: 'sdex', timestamp: baseTime + 20_000 }) + expect(tracker.getStatus()).toBe('deviating') + expect(alert).not.toHaveBeenCalled() + + // Sustained outside band -> fires + tracker.recordTick({ price: 0.97, venue: 'amm', timestamp: baseTime + 35_000 }) + expect(alert).toHaveBeenCalledTimes(1) + expect(tracker.getStatus()).toBe('depegged') + const payload = alert.mock.calls[0][0] + expect(payload.eventType).toBe('deviation') + expect(payload.band).toBe(0.02) + expect(payload.price).toBe(0.97) + }) + + it('supports band configured via bandBps (basis points)', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + bandBps: 50, // 50 bps = 0.005 (0.5%) + sustainedDurationMs: 5_000, + minSamples: 2, + }, + alert + ) + + const baseTime = 1_000_000 + tracker.recordTick({ price: 0.994, venue: 'sdex', timestamp: baseTime }) // 0.6% deviation > 0.5% + expect(tracker.getStatus()).toBe('deviating') + tracker.recordTick({ price: 0.993, venue: 'amm', timestamp: baseTime + 6_000 }) + expect(alert).toHaveBeenCalledTimes(1) + expect(alert.mock.calls[0][0].band).toBe(0.005) + }) + }) + + describe('Sustained duration requirement', () => { + it('does not fire until the deviation is sustained for the full required duration', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, // 1% + sustainedDurationMs: 30_000, + minSamples: 2, + }, + alert + ) + + const t0 = 1_700_000_000_000 + + // First tick at t0: deviated + tracker.recordTick({ price: 0.95, venue: 'sdex', timestamp: t0 }) + expect(tracker.getStatus()).toBe('deviating') + expect(alert).not.toHaveBeenCalled() + + // Second tick at t0 + 10s: still deviated, but only 10s elapsed (< 30s) + tracker.recordTick({ price: 0.95, venue: 'sdex', timestamp: t0 + 10_000 }) + expect(tracker.getStatus()).toBe('deviating') + expect(alert).not.toHaveBeenCalled() + + // Third tick at t0 + 29s: still deviated, but 29s elapsed (< 30s) + tracker.recordTick({ price: 0.94, venue: 'amm', timestamp: t0 + 29_000 }) + expect(tracker.getStatus()).toBe('deviating') + expect(alert).not.toHaveBeenCalled() + + // Fourth tick at t0 + 30s: exactly 30s elapsed (>= 30s) + const res = tracker.recordTick({ price: 0.94, venue: 'soroswap', timestamp: t0 + 30_000 }) + expect(alert).toHaveBeenCalledTimes(1) + expect(tracker.getStatus()).toBe('depegged') + expect(res).not.toBeNull() + expect(res?.eventType).toBe('deviation') + expect(res?.sustainedDurationMs).toBe(30_000) + }) + }) + + describe('Single outlier tick', () => { + it('a single outlier tick does not fire, proven by a test', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, + sustainedDurationMs: 20_000, + minSamples: 2, + }, + alert + ) + + const t0 = 1_700_000_000_000 + + // Normal tick + tracker.recordTick({ price: 1.00, venue: 'sdex', timestamp: t0 }) + expect(tracker.getStatus()).toBe('pegged') + expect(alert).not.toHaveBeenCalled() + + // SINGLE BAD TICK (outlier: flash wick to 0.88) + tracker.recordTick({ price: 0.88, venue: 'sdex', timestamp: t0 + 5_000 }) + expect(tracker.getStatus()).toBe('deviating') + expect(alert).not.toHaveBeenCalled() + + // Next tick immediately back to peg (1.00) + tracker.recordTick({ price: 1.00, venue: 'sdex', timestamp: t0 + 6_000 }) + + // State is restored to pegged without ANY alert having fired + expect(tracker.getStatus()).toBe('pegged') + expect(alert).not.toHaveBeenCalled() + + // Additional normal ticks continue normally + tracker.recordTick({ price: 0.999, venue: 'amm', timestamp: t0 + 25_000 }) + expect(tracker.getStatus()).toBe('pegged') + expect(alert).not.toHaveBeenCalled() + }) + }) + + describe('Venues carried on alerts', () => { + it('alerts carry all the venues the deviation was observed on', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, + sustainedDurationMs: 10_000, + minSamples: 3, + }, + alert + ) + + const t0 = 1_700_000_000_000 + + tracker.recordTick({ price: 0.95, venue: 'sdex', timestamp: t0 }) + tracker.recordTick({ price: 0.94, venue: 'amm', timestamp: t0 + 5_000 }) + tracker.recordTick({ price: 0.93, venue: 'soroswap', timestamp: t0 + 10_000 }) + + expect(alert).toHaveBeenCalledTimes(1) + const payload = alert.mock.calls[0][0] + expect(payload.venues).toEqual(expect.arrayContaining(['sdex', 'amm', 'soroswap'])) + expect(payload.provenance.venues).toEqual(expect.arrayContaining(['sdex', 'amm', 'soroswap'])) + expect(payload.provenance.sampleCount).toBe(3) + }) + }) + + describe('Recovery raises its own event', () => { + it('fires a recovery event and says when it came back with duration and details', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, + sustainedDurationMs: 10_000, + minSamples: 2, + }, + alert + ) + + const t0 = 1_700_000_000_000 + + // Step 1: De-peg + tracker.recordTick({ price: 0.92, venue: 'sdex', timestamp: t0 }) + tracker.recordTick({ price: 0.91, venue: 'amm', timestamp: t0 + 12_000 }) + expect(alert).toHaveBeenCalledTimes(1) + const depegPayload = alert.mock.calls[0][0] + expect(depegPayload.eventType).toBe('deviation') + expect(tracker.getStatus()).toBe('depegged') + + // Step 2: Continued deviation (no repeated spam) + tracker.recordTick({ price: 0.90, venue: 'sdex', timestamp: t0 + 20_000 }) + expect(alert).toHaveBeenCalledTimes(1) + + // Step 3: Recovery tick arrives at t0 + 60s + const recoveryTime = t0 + 60_000 + const recoveryRes = tracker.recordTick({ price: 1.001, venue: 'aquarius', timestamp: recoveryTime }) + + expect(alert).toHaveBeenCalledTimes(2) + expect(recoveryRes).not.toBeNull() + expect(recoveryRes?.eventType).toBe('recovery') + + const recoveryPayload = alert.mock.calls[1][0] + expect(recoveryPayload.eventType).toBe('recovery') + expect(recoveryPayload.asset).toBe('USDC') + expect(recoveryPayload.recoveredAt).toBe(new Date(recoveryTime).toISOString()) + expect(recoveryPayload.deviationStartedAt).toBe(new Date(t0).toISOString()) + expect(recoveryPayload.durationMs).toBe(60_000) + expect(recoveryPayload.price).toBe(1.001) + expect(recoveryPayload.venues).toEqual(expect.arrayContaining(['aquarius', 'sdex', 'amm'])) + expect(recoveryPayload.provenance.venues).toEqual(expect.arrayContaining(['aquarius', 'sdex', 'amm'])) + + // Tracker status is now back to pegged + expect(tracker.getStatus()).toBe('pegged') + + // Subsequent normal tick does not fire another recovery + tracker.recordTick({ price: 1.00, venue: 'sdex', timestamp: t0 + 70_000 }) + expect(alert).toHaveBeenCalledTimes(2) + }) + }) + + describe('Provenance and Honest Answers', () => { + it('ignores invalid, NaN, or non-positive prices and never treats them as 0', () => { + const alert = vi.fn() + const tracker = new PegDeviationTracker( + { + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, + sustainedDurationMs: 0, + }, + alert + ) + + expect(tracker.recordTick({ price: 0, venue: 'sdex' })).toBeNull() + expect(tracker.recordTick({ price: -1, venue: 'sdex' })).toBeNull() + expect(tracker.recordTick({ price: NaN, venue: 'sdex' })).toBeNull() + expect(tracker.recordTick({ price: Infinity, venue: 'sdex' })).toBeNull() + expect(alert).not.toHaveBeenCalled() + expect(tracker.getStatus()).toBe('pegged') + }) + + it('batch checkPegDeviation processes sequence of observations correctly', () => { + const baseTime = 1_700_000_000_000 + const observations: PegPriceObservation[] = [ + { price: 1.00, venue: 'sdex', timestamp: baseTime }, + { price: 0.94, venue: 'sdex', timestamp: baseTime + 10_000 }, + { price: 0.93, venue: 'amm', timestamp: baseTime + 25_000 }, // De-peg fires (sustained 15s >= 10s) + { price: 1.00, venue: 'sdex', timestamp: baseTime + 40_000 }, // Recovery fires + ] + + const alerts = checkPegDeviation(observations, { + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, + sustainedDurationMs: 10_000, + minSamples: 2, + }) + + expect(alerts).toHaveLength(2) + expect(alerts[0].eventType).toBe('deviation') + expect(alerts[1].eventType).toBe('recovery') + }) + }) + + describe('Webhook delivery integration', () => { + it('dispatches webhook using deliverJsonWithRetries when url and secret are provided', async () => { + const spy = vi.spyOn(delivery, 'deliverJsonWithRetries').mockResolvedValue(undefined) + + const tracker = new PegDeviationTracker({ + asset: 'USDC', + targetPrice: 1.0, + band: 0.01, + sustainedDurationMs: 5_000, + minSamples: 2, + url: 'https://example.com/peg-alert', + secret: 'supersecret', + }) + + const t0 = 1_700_000_000_000 + tracker.recordTick({ price: 0.92, venue: 'sdex', timestamp: t0 }) + tracker.recordTick({ price: 0.92, venue: 'amm', timestamp: t0 + 6_000 }) + + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith( + 'https://example.com/peg-alert', + expect.objectContaining({ eventType: 'deviation', asset: 'USDC' }), + 'supersecret' + ) + }) + }) +})