From d0c7dee5e0795676e1f4f52b2afe8d6d7a89e03b Mon Sep 17 00:00:00 2001 From: p70436464-prog Date: Fri, 16 Oct 2026 16:58:31 +0100 Subject: [PATCH] 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 {