From d0c7dee5e0795676e1f4f52b2afe8d6d7a89e03b Mon Sep 17 00:00:00 2001 From: p70436464-prog Date: Fri, 16 Oct 2026 16:58:31 +0100 Subject: [PATCH 1/2] feat: provide split routing recommendations minimizing slippage across venues - Calculate proportions per venue for a given size that minimise total slippage - Return split proportions only when splitting beats the single-venue route - Include single-venue alternative comparison in response - Add GET /price/:assetA/:assetB/split route filtering on and echoing network - Fully declare /price/{assetA}/{assetB}/split and schemas in openapi.yaml and openapi.json - Add vitest tests covering size where splitting wins and where it does not --- openapi.json | 274 +++++++++++++++++++ openapi.yaml | 156 +++++++++++ src/__tests__/schemaValidation.test.ts | 62 ++++- src/__tests__/splitRoute.test.ts | 210 +++++++++++++++ src/aggregator/bestRoute.ts | 1 + src/aggregator/splitRoute.ts | 351 +++++++++++++++++++++++++ src/api/rest.ts | 25 ++ src/api/schemas.ts | 100 +++++++ src/types.ts | 53 ++++ 9 files changed, 1230 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/splitRoute.test.ts create mode 100644 src/aggregator/splitRoute.ts diff --git a/openapi.json b/openapi.json index b0576e01..08d1eb2f 100644 --- a/openapi.json +++ b/openapi.json @@ -188,6 +188,68 @@ } } }, + "/price/{assetA}/{assetB}/split": { + "get": { + "operationId": "getSplitRecommendation", + "summary": "Get optimal split route proportions minimizing slippage", + "security": [ + { + "x402": [] + } + ], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "amount", + "in": "query", + "required": false, + "schema": { + "type": "number", + "default": 1000 + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Split recommendation and comparison", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SplitRecommendation" + } + } + } + }, + "402": { + "$ref": "#/components/responses/PaymentRequired" + }, + "404": { + "description": "Pair not watched or no liquidity" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + } + } + } + }, "/price/{assetA}/{assetB}/history": { "get": { "operationId": "getPriceHistory", @@ -1977,6 +2039,218 @@ }, "recommendation": { "type": "string" + }, + "split": { + "$ref": "#/components/schemas/SplitRecommendation" + } + } + }, + "SplitRecommendation": { + "type": "object", + "required": [ + "splitRecommended", + "assetA", + "assetB", + "pairKey", + "network", + "amount", + "message", + "provenance" + ], + "additionalProperties": false, + "properties": { + "splitRecommended": { + "type": "boolean" + }, + "assetA": { + "type": "string" + }, + "assetB": { + "type": "string" + }, + "pairKey": { + "type": "string" + }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, + "amount": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "message": { + "type": "string" + }, + "proportions": { + "type": "object", + "required": [ + "SDEX", + "AMM" + ], + "additionalProperties": false, + "properties": { + "SDEX": { + "type": "number" + }, + "AMM": { + "type": "number" + } + } + }, + "allocations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SplitVenueAllocation" + } + }, + "estimatedOutput": { + "type": "number" + }, + "effectivePrice": { + "type": "number" + }, + "slippagePct": { + "type": "number" + }, + "singleVenue": { + "type": "object", + "required": [ + "route", + "output", + "price", + "slippagePct" + ], + "additionalProperties": false, + "properties": { + "route": { + "type": "string", + "enum": [ + "SDEX", + "AMM" + ] + }, + "output": { + "type": "number" + }, + "price": { + "type": "number" + }, + "slippagePct": { + "type": "number" + } + } + }, + "comparison": { + "$ref": "#/components/schemas/SplitComparison" + }, + "provenance": { + "$ref": "#/components/schemas/SplitProvenance" + }, + "error": { + "type": "string" + } + } + }, + "SplitVenueAllocation": { + "type": "object", + "required": [ + "venue", + "proportion", + "amount", + "estimatedOutput", + "effectivePrice" + ], + "additionalProperties": false, + "properties": { + "venue": { + "type": "string", + "enum": [ + "SDEX", + "AMM" + ] + }, + "proportion": { + "type": "number" + }, + "amount": { + "type": "number" + }, + "estimatedOutput": { + "type": "number" + }, + "effectivePrice": { + "type": "number" + } + } + }, + "SplitComparison": { + "type": "object", + "required": [ + "singleVenueRoute", + "singleVenueOutput", + "singleVenueEffectivePrice", + "singleVenueSlippagePct", + "outputImprovement", + "outputImprovementPct" + ], + "additionalProperties": false, + "properties": { + "singleVenueRoute": { + "type": "string", + "enum": [ + "SDEX", + "AMM" + ] + }, + "singleVenueOutput": { + "type": "number" + }, + "singleVenueEffectivePrice": { + "type": "number" + }, + "singleVenueSlippagePct": { + "type": "number" + }, + "outputImprovement": { + "type": "number" + }, + "outputImprovementPct": { + "type": "number" + } + } + }, + "SplitProvenance": { + "type": "object", + "required": [ + "venues", + "samples", + "asOf", + "freshnessSeconds" + ], + "additionalProperties": false, + "properties": { + "venues": { + "type": "array", + "items": { + "type": "string" + } + }, + "samples": { + "type": "integer" + }, + "asOf": { + "type": "string", + "format": "date-time" + }, + "freshnessSeconds": { + "type": "integer", + "nullable": true } } }, diff --git a/openapi.yaml b/openapi.yaml index 182db46d..46ca5c6b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -119,6 +119,44 @@ paths: '429': $ref: '#/components/responses/TooManyRequests' + /price/{assetA}/{assetB}/split: + get: + operationId: getSplitRecommendation + summary: Get optimal split route proportions minimizing slippage + security: + - x402: [] + parameters: + - name: assetA + in: path + required: true + schema: + type: string + - name: assetB + in: path + required: true + schema: + type: string + - name: amount + in: query + required: false + schema: + type: number + default: 1000 + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Split recommendation and comparison + content: + application/json: + schema: + $ref: '#/components/schemas/SplitRecommendation' + '402': + $ref: '#/components/responses/PaymentRequired' + '404': + description: Pair not watched or no liquidity + '429': + $ref: '#/components/responses/TooManyRequests' + /price/{assetA}/{assetB}/history: get: operationId: getPriceHistory @@ -1272,6 +1310,124 @@ components: type: number recommendation: type: string + split: + $ref: '#/components/schemas/SplitRecommendation' + + SplitRecommendation: + type: object + required: [splitRecommended, assetA, assetB, pairKey, network, amount, message, provenance] + additionalProperties: false + properties: + splitRecommended: + type: boolean + assetA: + type: string + assetB: + type: string + pairKey: + type: string + network: + type: string + enum: [testnet, mainnet] + amount: + type: number + reason: + type: string + message: + type: string + proportions: + type: object + required: [SDEX, AMM] + additionalProperties: false + properties: + SDEX: + type: number + AMM: + type: number + allocations: + type: array + items: + $ref: '#/components/schemas/SplitVenueAllocation' + estimatedOutput: + type: number + effectivePrice: + type: number + slippagePct: + type: number + singleVenue: + type: object + required: [route, output, price, slippagePct] + additionalProperties: false + properties: + route: + type: string + enum: [SDEX, AMM] + output: + type: number + price: + type: number + slippagePct: + type: number + comparison: + $ref: '#/components/schemas/SplitComparison' + provenance: + $ref: '#/components/schemas/SplitProvenance' + error: + type: string + + SplitVenueAllocation: + type: object + required: [venue, proportion, amount, estimatedOutput, effectivePrice] + additionalProperties: false + properties: + venue: + type: string + enum: [SDEX, AMM] + proportion: + type: number + amount: + type: number + estimatedOutput: + type: number + effectivePrice: + type: number + + SplitComparison: + type: object + required: [singleVenueRoute, singleVenueOutput, singleVenueEffectivePrice, singleVenueSlippagePct, outputImprovement, outputImprovementPct] + additionalProperties: false + properties: + singleVenueRoute: + type: string + enum: [SDEX, AMM] + singleVenueOutput: + type: number + singleVenueEffectivePrice: + type: number + singleVenueSlippagePct: + type: number + outputImprovement: + type: number + outputImprovementPct: + type: number + + SplitProvenance: + type: object + required: [venues, samples, asOf, freshnessSeconds] + additionalProperties: false + properties: + venues: + type: array + items: + type: string + samples: + type: integer + asOf: + type: string + format: date-time + freshnessSeconds: + type: integer + nullable: true PriceBucket: type: object diff --git a/src/__tests__/schemaValidation.test.ts b/src/__tests__/schemaValidation.test.ts index 12ec5b4b..c0da44a8 100644 --- a/src/__tests__/schemaValidation.test.ts +++ b/src/__tests__/schemaValidation.test.ts @@ -2,12 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Fastify from 'fastify' import Ajv from 'ajv' -const { mockQuery, mockGetCachedPrice, mockGetBestRoute, mockGetAggregatedPrice, mockGetDepth } = vi.hoisted(() => ({ +const { mockQuery, mockGetCachedPrice, mockGetBestRoute, mockGetAggregatedPrice, mockGetDepth, mockGetSplitRecommendation } = vi.hoisted(() => ({ mockQuery: vi.fn(), mockGetCachedPrice: vi.fn(), mockGetBestRoute: vi.fn(), mockGetAggregatedPrice: vi.fn(), mockGetDepth: vi.fn(), + mockGetSplitRecommendation: vi.fn(), })) vi.mock('../db', () => ({ @@ -23,6 +24,10 @@ vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: mockGetBestRoute, })) +vi.mock('../aggregator/splitRoute', () => ({ + getSplitRecommendation: mockGetSplitRecommendation, +})) + vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: mockGetAggregatedPrice, })) @@ -53,7 +58,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 }) @@ -141,4 +146,57 @@ describe('REST response schema validation', () => { expect(res.statusCode).toBe(500) }) + + it('returns 200 and a body that matches the declared /price/:assetA/:assetB/split schema', async () => { + mockGetSplitRecommendation.mockResolvedValue({ + splitRecommended: true, + assetA: 'XLM', + assetB: 'USDC', + pairKey: 'USDC/XLM', + network: 'testnet', + amount: 1000, + message: 'Splitting across SDEX and AMM yields 2.50% better output than single-venue route (SDEX)', + proportions: { + SDEX: 0.4, + AMM: 0.6, + }, + allocations: [ + { venue: 'SDEX', proportion: 0.4, amount: 400, estimatedOutput: 390, effectivePrice: 0.975 }, + { venue: 'AMM', proportion: 0.6, amount: 600, estimatedOutput: 580, effectivePrice: 0.9667 }, + ], + estimatedOutput: 970, + effectivePrice: 0.97, + slippagePct: 1.5, + singleVenue: { + route: 'SDEX', + output: 950, + price: 0.95, + slippagePct: 2.0, + }, + comparison: { + singleVenueRoute: 'SDEX', + singleVenueOutput: 950, + singleVenueEffectivePrice: 0.95, + singleVenueSlippagePct: 2.0, + outputImprovement: 20, + outputImprovementPct: 2.11, + }, + provenance: { + venues: ['SDEX', 'AMM'], + samples: 20, + asOf: '2026-10-16T12:00:00.000Z', + freshnessSeconds: 15, + }, + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/split' }) + + expect(res.statusCode).toBe(200) + + const ajv = new Ajv({ allErrors: true }) + const validate = ajv.compile(splitResponseSchema as object) + const ok = validate(res.json()) + expect(ok, ajv.errorsText(validate.errors)).toBe(true) + }) }) 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 e9ee02cb..64b28bd6 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 } 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/api/rest.ts b/src/api/rest.ts index 8f386e6a..e4680385 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -3,6 +3,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 '../middleware/network' // declares req.network on the FastifyRequest type @@ -10,6 +11,7 @@ import { statusResponseSchema, priceResponseSchema, routeResponseSchema, + splitResponseSchema, historyResponseSchema, poolsResponseSchema, depthResponseSchema, @@ -155,6 +157,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/src/api/schemas.ts b/src/api/schemas.ts index 90ecd10f..e931d76d 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -107,6 +107,106 @@ export const routeResponseSchema = { estimatedOutput: { type: 'number' }, slippagePct: { type: 'number' }, recommendation: { type: 'string' }, + split: { + type: 'object', + additionalProperties: true, + }, + }, +} as const + +/** GET /price/:assetA/:assetB/split — matches SplitRecommendation */ +export const splitResponseSchema = { + type: 'object', + required: [ + 'splitRecommended', + 'assetA', + 'assetB', + 'pairKey', + 'network', + 'amount', + 'message', + 'provenance', + ], + additionalProperties: false, + properties: { + splitRecommended: { type: 'boolean' }, + assetA: { type: 'string' }, + assetB: { type: 'string' }, + pairKey: { type: 'string' }, + network: { type: 'string', enum: ['testnet', 'mainnet'] }, + amount: { type: 'number' }, + reason: { type: 'string' }, + message: { type: 'string' }, + proportions: { + type: 'object', + required: ['SDEX', 'AMM'], + additionalProperties: false, + properties: { + SDEX: { type: 'number' }, + AMM: { type: 'number' }, + }, + }, + allocations: { + type: 'array', + items: { + type: 'object', + required: ['venue', 'proportion', 'amount', 'estimatedOutput', 'effectivePrice'], + additionalProperties: false, + properties: { + venue: { type: 'string', enum: ['SDEX', 'AMM'] }, + proportion: { type: 'number' }, + amount: { type: 'number' }, + estimatedOutput: { type: 'number' }, + effectivePrice: { type: 'number' }, + }, + }, + }, + estimatedOutput: { type: 'number' }, + effectivePrice: { type: 'number' }, + slippagePct: { type: 'number' }, + singleVenue: { + type: 'object', + required: ['route', 'output', 'price', 'slippagePct'], + additionalProperties: false, + properties: { + route: { type: 'string', enum: ['SDEX', 'AMM'] }, + output: { type: 'number' }, + price: { type: 'number' }, + slippagePct: { type: 'number' }, + }, + }, + comparison: { + type: 'object', + required: [ + 'singleVenueRoute', + 'singleVenueOutput', + 'singleVenueEffectivePrice', + 'singleVenueSlippagePct', + 'outputImprovement', + 'outputImprovementPct', + ], + additionalProperties: false, + properties: { + singleVenueRoute: { type: 'string', enum: ['SDEX', 'AMM'] }, + singleVenueOutput: { type: 'number' }, + singleVenueEffectivePrice: { type: 'number' }, + singleVenueSlippagePct: { type: 'number' }, + outputImprovement: { type: 'number' }, + outputImprovementPct: { type: 'number' }, + }, + }, + provenance: { + type: 'object', + required: ['venues', 'samples', 'asOf', 'freshnessSeconds'], + additionalProperties: false, + properties: { + venues: { type: 'array', items: { type: 'string' } }, + samples: { type: 'integer' }, + asOf: { type: 'string' }, + freshnessSeconds: { type: ['integer', 'null'] }, + }, + }, + error: { type: 'string' }, }, } as const diff --git a/src/types.ts b/src/types.ts index 98284a96..99cd7133 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,58 @@ export interface AggregatedPrice { stale: boolean } +export interface SplitVenueAllocation { + venue: 'SDEX' | 'AMM' + proportion: number + amount: number + estimatedOutput: number + effectivePrice: number +} + +export interface SplitComparison { + singleVenueRoute: 'SDEX' | 'AMM' + singleVenueOutput: number + singleVenueEffectivePrice: number + singleVenueSlippagePct: number + outputImprovement: number + outputImprovementPct: number +} + +export interface SplitProvenance { + venues: string[] + samples: number + asOf: string + freshnessSeconds: number | null +} + +export interface SplitRecommendation { + splitRecommended: boolean + assetA: string + assetB: string + pairKey: string + network: string + amount: number + reason?: string + message: string + proportions?: { + SDEX: number + AMM: number + } + allocations?: SplitVenueAllocation[] + estimatedOutput?: number + effectivePrice?: number + slippagePct?: number + singleVenue?: { + route: 'SDEX' | 'AMM' + output: number + price: number + slippagePct: number + } + comparison?: SplitComparison + provenance: SplitProvenance + error?: string +} + export interface RouteInfo { route: 'SDEX' | 'AMM' | 'SPLIT' | 'UNKNOWN' sdexPrice: number @@ -43,6 +95,7 @@ export interface RouteInfo { estimatedOutput: number slippagePct: number recommendation: string + split?: SplitRecommendation } export interface WatchedPair { From 3b78c1f950dd0db5a4f222d7095b38d6fe17af7e Mon Sep 17 00:00:00 2001 From: p70436464-prog Date: Fri, 16 Oct 2026 17:39:03 +0100 Subject: [PATCH 2/2] feat(alerts): add peg-deviation alert with configurable band and recovery event --- src/alerts/index.ts | 7 +- src/alerts/pegDeviation.ts | 432 +++++++++++++++++++++++++++++++++++++ tests/pegDeviation.test.ts | 311 ++++++++++++++++++++++++++ 3 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 src/alerts/pegDeviation.ts create mode 100644 tests/pegDeviation.test.ts diff --git a/src/alerts/index.ts b/src/alerts/index.ts index 0dc079f9..9f6e535d 100644 --- a/src/alerts/index.ts +++ b/src/alerts/index.ts @@ -1,3 +1,4 @@ -export * from './delivery' -export * from './threshold' -export * from './arbitrage' \ No newline at end of file +export * from './delivery' +export * from './threshold' +export * from './arbitrage' +export * from './pegDeviation' \ No newline at end of file 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/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' + ) + }) + }) +})