diff --git a/.env.example b/.env.example index 3937aedc..99bc2e00 100644 --- a/.env.example +++ b/.env.example @@ -225,3 +225,8 @@ MCP_PAID_CALL_ALLOWED_HOSTS= # against a table that has never been pruned cannot hold a long lock or blow # the WAL. Default 1000. SNAPSHOT_PRUNE_BATCH_SIZE=1000 + +# ─── CORS policy ─────────────────────────────────────────────────────────── +# Comma-separated list of origins allowed on credentialed and admin routes. +# Public read routes remain open to all origins. +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173,https://app.stellar-lens.com diff --git a/openapi.json b/openapi.json index 94cb0a74..f484a75d 100644 --- a/openapi.json +++ b/openapi.json @@ -401,6 +401,26 @@ "parameters": [ { "$ref": "#/components/parameters/NetworkQuery" + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Base64 opaque cursor from previous page" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + }, + "description": "Number of pools to return per page (max 100)" } ], "responses": { @@ -445,9 +465,20 @@ "timestamp": { "type": "string", "format": "date-time" + }, + "venue": { + "type": "string", + "description": "Venue originating this pool (e.g. sdex, phoenix, aquarius, soroswap)" } } } + }, + "nextCursor": { + "type": "string", + "nullable": true + }, + "hasMore": { + "type": "boolean" } } } @@ -750,6 +781,56 @@ "parameters": [ { "$ref": "#/components/parameters/NetworkQuery" + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Base64 opaque cursor from previous page" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + }, + "description": "Number of pairs to return per page" + }, + { + "name": "min_volume", + "in": "query", + "required": false, + "schema": { + "type": "number", + "default": 10 + }, + "description": "Minimum 24h volume for tradeable classification" + }, + { + "name": "min_venues", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + }, + "description": "Minimum active venues for tradeable classification" + }, + { + "name": "ref_size", + "in": "query", + "required": false, + "schema": { + "type": "number", + "default": 100 + }, + "description": "Reference trade depth size for tradeable classification" } ], "responses": { @@ -787,9 +868,40 @@ "type": "string", "format": "date-time", "nullable": true + }, + "tradeable": { + "type": "boolean", + "description": "Indicates whether this pair meets active trading criteria" + }, + "tradeability": { + "type": "object", + "properties": { + "tradeable": { + "type": "boolean" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "criteria": { + "type": "object" + } + } } } } + }, + "nextCursor": { + "type": "string", + "nullable": true + }, + "hasMore": { + "type": "boolean" + }, + "criteria": { + "type": "object" } } } @@ -2052,133 +2164,668 @@ } } } - } - }, - "components": { - "securitySchemes": { - "x402": { - "type": "apiKey", - "name": "X-PAYMENT", - "in": "header", - "description": "Base64 encoded JSON payment proof for x402 micropayments." - }, - "adminAuth": { - "type": "apiKey", - "name": "X-Admin-Key", - "in": "header" - }, - "apiKeyAuth": { - "type": "http", - "scheme": "bearer", - "description": "Client API key issued through the admin API, sent as `Authorization: Bearer `." - } - }, - "parameters": { - "NetworkQuery": { - "name": "network", - "in": "query", - "required": false, - "description": "Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested.", - "schema": { - "type": "string", - "enum": [ - "testnet", - "mainnet" - ], - "default": "testnet" - } - } }, - "responses": { - "PaymentRequired": { - "description": "Payment Required", - "content": { - "application/json": { + "/volatility/{pairKey}": { + "get": { + "operationId": "getVolatility", + "summary": "Get realised volatility for a trading pair", + "security": [], + "parameters": [ + { + "name": "pairKey", + "in": "path", + "required": true, "schema": { - "type": "object", - "properties": { - "x402Version": { - "type": "integer" - }, - "accepts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "scheme": { - "type": "string" - }, - "price": { - "type": "string" - }, - "network": { - "type": "string" - }, - "payTo": { - "type": "string" - } + "type": "string" + }, + "example": "XLM/USDC" + }, + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1h", + "24h", + "7d", + "30d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Realised volatility data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pairKey": { + "type": "string" + }, + "network": { + "type": "string" + }, + "window": { + "type": "string" + }, + "method": { + "type": "string" + }, + "annualizedVolatility": { + "type": "number" + }, + "sampleCount": { + "type": "integer" + }, + "periodStart": { + "type": "string" + }, + "periodEnd": { + "type": "string" } } - }, - "error": { - "type": "string" - }, - "description": { - "type": "string" } } } + }, + "400": { + "description": "Insufficient data or invalid parameter" } } - }, - "TooManyRequests": { - "description": "Rate limit exceeded", - "headers": { - "Retry-After": { + } + }, + "/correlation": { + "get": { + "operationId": "getCorrelation", + "summary": "Get Pearson correlation matrix between Stellar assets", + "security": [], + "parameters": [ + { + "name": "assets", + "in": "query", + "required": true, "schema": { - "type": "integer" + "type": "string" }, - "description": "Seconds to wait before retrying" + "example": "XLM,USDC,EURC" }, - "X-RateLimit-Remaining": { - "schema": { - "type": "integer" - }, - "description": "Remaining requests in current window" - } - }, - "content": { - "application/json": { + { + "name": "window", + "in": "query", + "required": false, "schema": { - "type": "object", - "properties": { - "statusCode": { - "type": "integer" - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - }, - "retryAfter": { - "type": "string" - } - } + "type": "string", + "enum": [ + "1h", + "24h", + "7d", + "30d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Correlation matrix", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "window": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "type": "string" + } + }, + "matrix": { + "type": "object" + } + } + } + } + } + }, + "400": { + "description": "Invalid request" + } + } + } + }, + "/movers": { + "get": { + "operationId": "getMovers", + "summary": "Get leaderboard of top gainers, losers, and volume movers", + "security": [], + "parameters": [ + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1h", + "24h", + "7d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Top movers leaderboard", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "window": { + "type": "string" + }, + "gainers": { + "type": "array", + "items": { + "type": "object" + } + }, + "losers": { + "type": "array", + "items": { + "type": "object" + } + }, + "volumeMovers": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } } } } } }, - "headers": { - "CacheControl": { - "description": "Per-route cache policy (see \"Cache policy\" in the API description). Every route sends a deliberate `Cache-Control`: live prices `public, max-age=30`; aggregates `public, max-age=60`; `/pairs` and `/pools` `public, max-age=300`; closed candle windows `public, max-age=86400`; per-caller, operator and error responses `no-store`.", - "schema": { - "type": "string", - "example": "public, max-age=300" - } - } - }, + "/impact/{assetA}/{assetB}": { + "get": { + "operationId": "getPriceImpact", + "summary": "Get size-ladder slippage and price impact curve", + "security": [], + "parameters": [ + { + "name": "assetA", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "XLM" + }, + { + "name": "assetB", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "USDC" + }, + { + "name": "sizes", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Optional comma-separated list of sizes" + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Slippage impact ladder", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pair": { + "type": "string" + }, + "network": { + "type": "string" + }, + "tradeable": { + "type": "boolean" + }, + "spotPrice": { + "type": "number", + "nullable": true + }, + "ladder": { + "type": "array", + "items": { + "type": "object" + } + }, + "thresholdCrossings": { + "type": "object" + } + } + } + } + } + } + } + } + }, + "/yield": { + "get": { + "operationId": "getPoolYields", + "summary": "Get fee APR and impermanent loss for liquidity pools", + "security": [], + "parameters": [ + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Pool yields list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "window": { + "type": "string" + }, + "yields": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + } + } + } + }, + "/pools/{id}/il": { + "get": { + "operationId": "getPoolImpermanentLoss", + "summary": "Get divergence and impermanent loss for a specific pool", + "security": [], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "24h", + "7d", + "30d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Pool impermanent loss and net yield", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "poolId": { + "type": "string" + }, + "network": { + "type": "string" + }, + "window": { + "type": "string" + }, + "currentPrice": { + "type": "number" + }, + "startPrice": { + "type": "number" + }, + "priceRatio": { + "type": "number" + }, + "feeApr": { + "type": "number" + }, + "impermanentLoss": { + "type": "number" + }, + "netYield": { + "type": "number" + }, + "assumptions": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Pool not found" + } + } + } + }, + "/pegs": { + "get": { + "operationId": "getPegs", + "summary": "Get comparative stablecoin multi-peg health", + "security": [], + "parameters": [ + { + "name": "window", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1h", + "24h", + "7d", + "30d" + ], + "default": "24h" + } + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Multi-peg comparative status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "window": { + "type": "string" + }, + "pegs": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + } + } + } + }, + "/rates/{asset}": { + "get": { + "operationId": "getLendingRates", + "summary": "Get lending market supply and borrow rates", + "security": [], + "parameters": [ + { + "name": "asset", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "example": "USDC" + }, + { + "$ref": "#/components/parameters/NetworkQuery" + } + ], + "responses": { + "200": { + "description": "Lending market rates per venue", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "venue": { + "type": "string" + }, + "pool": { + "type": "string" + }, + "poolId": { + "type": "string" + }, + "asset": { + "type": "string" + }, + "network": { + "type": "string" + }, + "supplyApy": { + "type": "number" + }, + "borrowApy": { + "type": "number" + }, + "utilizationRate": { + "type": "number" + }, + "timestamp": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "x402": { + "type": "apiKey", + "name": "X-PAYMENT", + "in": "header", + "description": "Base64 encoded JSON payment proof for x402 micropayments." + }, + "adminAuth": { + "type": "apiKey", + "name": "X-Admin-Key", + "in": "header" + }, + "apiKeyAuth": { + "type": "http", + "scheme": "bearer", + "description": "Client API key issued through the admin API, sent as `Authorization: Bearer `." + } + }, + "parameters": { + "NetworkQuery": { + "name": "network", + "in": "query", + "required": false, + "description": "Stellar network to answer for — `testnet` or `mainnet`. Defaults to this instance's configured `STELLAR_NETWORK`. An unrecognised value is rejected with 400. DB-backed reads are currently served from whichever network this instance indexes, even when another is requested.", + "schema": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ], + "default": "testnet" + } + } + }, + "responses": { + "PaymentRequired": { + "description": "Payment Required", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "x402Version": { + "type": "integer" + }, + "accepts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scheme": { + "type": "string" + }, + "price": { + "type": "string" + }, + "network": { + "type": "string" + }, + "payTo": { + "type": "string" + } + } + } + }, + "error": { + "type": "string" + }, + "description": { + "type": "string" + } + } + } + } + } + }, + "TooManyRequests": { + "description": "Rate limit exceeded", + "headers": { + "Retry-After": { + "schema": { + "type": "integer" + }, + "description": "Seconds to wait before retrying" + }, + "X-RateLimit-Remaining": { + "schema": { + "type": "integer" + }, + "description": "Remaining requests in current window" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "statusCode": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "retryAfter": { + "type": "string" + } + } + } + } + } + } + }, + "headers": { + "CacheControl": { + "description": "Per-route cache policy (see \"Cache policy\" in the API description). Every route sends a deliberate `Cache-Control`: live prices `public, max-age=30`; aggregates `public, max-age=60`; `/pairs` and `/pools` `public, max-age=300`; closed candle windows `public, max-age=86400`; per-caller, operator and error responses `no-store`.", + "schema": { + "type": "string", + "example": "public, max-age=300" + } + } + }, "schemas": { "Asset": { "type": "object", @@ -2777,6 +3424,38 @@ }, "tradeCount": { "type": "integer" + }, + "medianSize": { + "type": "number" + }, + "largestTradeVolume": { + "type": "number" + }, + "topTradeShare": { + "type": "number" + }, + "concentrationScore": { + "type": "number" + }, + "concentration": { + "type": "object", + "properties": { + "medianSize": { + "type": "number" + }, + "largestTradeVolume": { + "type": "number" + }, + "topTradeShare": { + "type": "number" + }, + "herfindahlIndex": { + "type": "number" + }, + "method": { + "type": "string" + } + } } } }, @@ -3428,6 +4107,45 @@ } } } + }, + "ErrorCode": { + "type": "string", + "enum": [ + "UNKNOWN_PAIR", + "UNWATCHED_PAIR", + "BAD_INTERVAL", + "BAD_WINDOW", + "BAD_NETWORK", + "MISSING_PARAMETER", + "UPSTREAM_UNAVAILABLE", + "INSUFFICIENT_DATA", + "NOT_FOUND", + "INVALID_REQUEST", + "UNAUTHORIZED", + "RATE_LIMITED", + "INTERNAL_ERROR" + ], + "description": "Canonical machine-readable error codes (#261)" + }, + "AppError": { + "type": "object", + "required": [ + "error", + "code" + ], + "properties": { + "error": { + "type": "string", + "description": "Human-readable error message" + }, + "code": { + "$ref": "#/components/schemas/ErrorCode" + }, + "requestId": { + "type": "string", + "description": "Request tracing ID (#265)" + } + } } } } diff --git a/openapi.yaml b/openapi.yaml index eb49b630..8d17210e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -259,6 +259,20 @@ paths: - x402: [] parameters: - $ref: '#/components/parameters/NetworkQuery' + - name: cursor + in: query + required: false + schema: + type: string + description: Base64 opaque cursor from previous page + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + maximum: 100 + description: Number of pools to return per page (max 100) responses: '200': description: List of pools @@ -292,6 +306,14 @@ paths: timestamp: type: string format: date-time + venue: + type: string + description: Venue originating this pool (e.g. sdex, phoenix, aquarius, soroswap) + nextCursor: + type: string + nullable: true + hasMore: + type: boolean '402': $ref: '#/components/responses/PaymentRequired' '429': @@ -481,6 +503,41 @@ paths: security: [] parameters: - $ref: '#/components/parameters/NetworkQuery' + - name: cursor + in: query + required: false + schema: + type: string + description: Base64 opaque cursor from previous page + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + maximum: 100 + description: Number of pairs to return per page + - name: min_volume + in: query + required: false + schema: + type: number + default: 10 + description: Minimum 24h volume for tradeable classification + - name: min_venues + in: query + required: false + schema: + type: integer + default: 1 + description: Minimum active venues for tradeable classification + - name: ref_size + in: query + required: false + schema: + type: number + default: 100 + description: Reference trade depth size for tradeable classification responses: '200': description: List of pairs @@ -510,6 +567,27 @@ paths: type: string format: date-time nullable: true + tradeable: + type: boolean + description: Indicates whether this pair meets active trading criteria + tradeability: + type: object + properties: + tradeable: + type: boolean + reasons: + type: array + items: + type: string + criteria: + type: object + nextCursor: + type: string + nullable: true + hasMore: + type: boolean + criteria: + type: object '429': $ref: '#/components/responses/TooManyRequests' post: @@ -1325,6 +1403,336 @@ paths: '402': $ref: '#/components/responses/PaymentRequired' + /volatility/{pairKey}: + get: + operationId: getVolatility + summary: Get realised volatility for a trading pair + security: [] + parameters: + - name: pairKey + in: path + required: true + schema: + type: string + example: XLM/USDC + - name: window + in: query + required: false + schema: + type: string + enum: [1h, 24h, 7d, 30d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Realised volatility data + content: + application/json: + schema: + type: object + properties: + pairKey: + type: string + network: + type: string + window: + type: string + method: + type: string + annualizedVolatility: + type: number + sampleCount: + type: integer + periodStart: + type: string + periodEnd: + type: string + '400': + description: Insufficient data or invalid parameter + + /correlation: + get: + operationId: getCorrelation + summary: Get Pearson correlation matrix between Stellar assets + security: [] + parameters: + - name: assets + in: query + required: true + schema: + type: string + example: XLM,USDC,EURC + - name: window + in: query + required: false + schema: + type: string + enum: [1h, 24h, 7d, 30d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Correlation matrix + content: + application/json: + schema: + type: object + properties: + network: + type: string + window: + type: string + assets: + type: array + items: + type: string + matrix: + type: object + '400': + description: Invalid request + + /movers: + get: + operationId: getMovers + summary: Get leaderboard of top gainers, losers, and volume movers + security: [] + parameters: + - name: window + in: query + required: false + schema: + type: string + enum: [1h, 24h, 7d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Top movers leaderboard + content: + application/json: + schema: + type: object + properties: + network: + type: string + window: + type: string + gainers: + type: array + items: + type: object + losers: + type: array + items: + type: object + volumeMovers: + type: array + items: + type: object + + /impact/{assetA}/{assetB}: + get: + operationId: getPriceImpact + summary: Get size-ladder slippage and price impact curve + security: [] + parameters: + - name: assetA + in: path + required: true + schema: + type: string + example: XLM + - name: assetB + in: path + required: true + schema: + type: string + example: USDC + - name: sizes + in: query + required: false + schema: + type: string + description: Optional comma-separated list of sizes + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Slippage impact ladder + content: + application/json: + schema: + type: object + properties: + pair: + type: string + network: + type: string + tradeable: + type: boolean + spotPrice: + type: number + nullable: true + ladder: + type: array + items: + type: object + thresholdCrossings: + type: object + + /yield: + get: + operationId: getPoolYields + summary: Get fee APR and impermanent loss for liquidity pools + security: [] + parameters: + - name: window + in: query + required: false + schema: + type: string + enum: [24h, 7d, 30d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Pool yields list + content: + application/json: + schema: + type: object + properties: + network: + type: string + window: + type: string + yields: + type: array + items: + type: object + + /pools/{id}/il: + get: + operationId: getPoolImpermanentLoss + summary: Get divergence and impermanent loss for a specific pool + security: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: window + in: query + required: false + schema: + type: string + enum: [24h, 7d, 30d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Pool impermanent loss and net yield + content: + application/json: + schema: + type: object + properties: + poolId: + type: string + network: + type: string + window: + type: string + currentPrice: + type: number + startPrice: + type: number + priceRatio: + type: number + feeApr: + type: number + impermanentLoss: + type: number + netYield: + type: number + assumptions: + type: string + '404': + description: Pool not found + + /pegs: + get: + operationId: getPegs + summary: Get comparative stablecoin multi-peg health + security: [] + parameters: + - name: window + in: query + required: false + schema: + type: string + enum: [1h, 24h, 7d, 30d] + default: 24h + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Multi-peg comparative status + content: + application/json: + schema: + type: object + properties: + network: + type: string + window: + type: string + pegs: + type: array + items: + type: object + + /rates/{asset}: + get: + operationId: getLendingRates + summary: Get lending market supply and borrow rates + security: [] + parameters: + - name: asset + in: path + required: true + schema: + type: string + example: USDC + - $ref: '#/components/parameters/NetworkQuery' + responses: + '200': + description: Lending market rates per venue + content: + application/json: + schema: + type: array + items: + type: object + properties: + venue: + type: string + pool: + type: string + poolId: + type: string + asset: + type: string + network: + type: string + supplyApy: + type: number + borrowApy: + type: number + utilizationRate: + type: number + timestamp: + type: string + components: securitySchemes: x402: @@ -1764,6 +2172,27 @@ components: type: string tradeCount: type: integer + medianSize: + type: number + largestTradeVolume: + type: number + topTradeShare: + type: number + concentrationScore: + type: number + concentration: + type: object + properties: + medianSize: + type: number + largestTradeVolume: + type: number + topTradeShare: + type: number + herfindahlIndex: + type: number + method: + type: string SpreadVenue: type: object @@ -2149,3 +2578,34 @@ components: properties: message: type: string + + ErrorCode: + type: string + enum: + - UNKNOWN_PAIR + - UNWATCHED_PAIR + - BAD_INTERVAL + - BAD_WINDOW + - BAD_NETWORK + - MISSING_PARAMETER + - UPSTREAM_UNAVAILABLE + - INSUFFICIENT_DATA + - NOT_FOUND + - INVALID_REQUEST + - UNAUTHORIZED + - RATE_LIMITED + - INTERNAL_ERROR + description: Canonical machine-readable error codes (#261) + + AppError: + type: object + required: [error, code] + properties: + error: + type: string + description: Human-readable error message + code: + $ref: '#/components/schemas/ErrorCode' + requestId: + type: string + description: Request tracing ID (#265) diff --git a/src/__tests__/analyticsAndContractHardeningCluster.test.ts b/src/__tests__/analyticsAndContractHardeningCluster.test.ts new file mode 100644 index 00000000..4768d09f --- /dev/null +++ b/src/__tests__/analyticsAndContractHardeningCluster.test.ts @@ -0,0 +1,639 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ + mockQuery: vi.fn(), +})) + +vi.mock('../db', () => ({ + pgPool: { query: mockQuery }, + prisma: {}, +})) + +import { buildApp } from '../app' +import { + computeRealisedVolatility, + computePearsonCorrelation, +} from '../routes/analytics' +import { + computeFeeApr, + computeImpermanentLoss, +} from '../routes/yield' +import { + evaluatePegTick, + createInitialPegState, +} from '../alerts/peg' +import { + deriveDefindexPricePoints, + _resetLastPrice as resetDefindexLastPrice, +} from '../ingest/venues/defindex' +import { ErrorCodes, ALL_ERROR_CODES } from '../errors' + +describe('Analytics & API Contract Hardening Mega Cluster (20 Issues)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockQuery.mockResolvedValue({ rows: [] }) + process.env.REQUIRE_API_KEY = 'false' + resetDefindexLastPrice() + }) + + // ========================================== + // Pillar 1: Advanced Analytics, Market Dynamics & Defi Yield + // ========================================== + + describe('#294: Realised Volatility Endpoint (GET /volatility/:pairKey)', () => { + it('computes log-returns annualised standard deviation against hand-computed example', () => { + // Prices: 100, 105, 102, 108 + // r1 = ln(105/100) = 0.048790 + // r2 = ln(102/105) = -0.028988 + // r3 = ln(108/102) = 0.057158 + // mean = (0.048790 - 0.028988 + 0.057158) / 3 = 0.025653 + // diffs^2: (0.048790 - 0.025653)^2 = 0.0005353 + // (-0.028988 - 0.025653)^2 = 0.0029857 + // (0.057158 - 0.025653)^2 = 0.0009926 + // variance = 0.0045136 / 2 = 0.0022568 + // stddev = sqrt(0.0022568) = 0.047506 + // annualised (factor 365*24 = 8760) = 0.047506 * 93.59487 = 4.4463 + const prices = [100, 105, 102, 108] + const result = computeRealisedVolatility(prices, 365 * 24) + expect(result.sampleCount).toBe(4) + expect(result.volatility).toBeCloseTo(4.4463, 2) + }) + + it('refuses when sample count is less than 4 with INSUFFICIENT_DATA', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { price: 0.12, timestamp: new Date().toISOString() }, + { price: 0.13, timestamp: new Date().toISOString() }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/volatility/XLM-USDC', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.code).toBe(ErrorCodes.INSUFFICIENT_DATA) + expect(body.sampleCount).toBe(2) + expect(body.minimumRequired).toBe(4) + await app.close() + }) + + it('returns 200 with method and sampleCount when sufficient samples exist', async () => { + const app = await buildApp() + const now = Date.now() + mockQuery.mockResolvedValueOnce({ + rows: [ + { price: 100, timestamp: new Date(now - 30000).toISOString() }, + { price: 105, timestamp: new Date(now - 20000).toISOString() }, + { price: 102, timestamp: new Date(now - 10000).toISOString() }, + { price: 108, timestamp: new Date(now).toISOString() }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/volatility/XLM-USDC?window=24h', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.method).toBe('log_returns_annualized_stddev') + expect(body.sampleCount).toBe(4) + expect(body.annualizedVolatility).toBeGreaterThan(0) + await app.close() + }) + }) + + describe('#293: Correlation Matrix (GET /correlation)', () => { + it('computes exact Pearson correlation for hand-computed series', () => { + const x = [1, 2, 3, 4, 5] + const y = [2, 4, 6, 8, 10] // perfectly positively correlated + expect(computePearsonCorrelation(x, y)).toBe(1.0) + + const yOpposite = [10, 8, 6, 4, 2] // perfectly negatively correlated + expect(computePearsonCorrelation(x, yOpposite)).toBe(-1.0) + }) + + it('reports insufficient_data when overlap count is below 3', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { asset: 'XLM', hour_bucket: '2026-01-01T01:00:00Z', avg_price: 0.12 }, + { asset: 'USDC', hour_bucket: '2026-01-01T01:00:00Z', avg_price: 1.0 }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/correlation?assets=XLM,USDC', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.matrix.XLM.USDC.status).toBe('insufficient_data') + expect(body.matrix.XLM.USDC.correlation).toBeNull() + await app.close() + }) + }) + + describe('#292: Movers Leaderboard (GET /movers)', () => { + it('returns gainers, losers, and volume movers with single-venue labelling', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { + pair_key: 'XLM/USDC', + total_volume: 50000, + venues: ['sdex', 'aquarius'], + current_price: 0.15, + previous_price: 0.10, + change_percent: 50.0, + }, + { + pair_key: 'AQUA/XLM', + total_volume: 12000, + venues: ['aquarius'], + current_price: 0.005, + previous_price: 0.010, + change_percent: -50.0, + }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/movers?window=24h', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.gainers[0].pair).toBe('XLM/USDC') + expect(body.gainers[0].singleVenue).toBe(false) + expect(body.losers[0].pair).toBe('AQUA/XLM') + expect(body.losers[0].singleVenue).toBe(true) + await app.close() + }) + }) + + describe('#302: Volume Concentration & Herfindahl Index (/volumes/:asset)', () => { + it('computes concentration metrics and distinguishes one-large-trade from many-small-trades', async () => { + const app = await buildApp() + + // Scenario: 1 large trade of 1000 + mockQuery + .mockResolvedValueOnce({ + rows: [{ source: 'sdex', volume: '1000', trade_count: 1 }], + }) + .mockResolvedValueOnce({ + rows: [{ trade_size: 1000 }], + }) + + const res1 = await app.inject({ + method: 'GET', + url: '/volumes/USDC?window=24h', + }) + expect(res1.statusCode).toBe(200) + const body1 = res1.json() + expect(body1.totalVolume).toBe(1000) + expect(body1.topTradeShare).toBe(1.0) + expect(body1.concentrationScore).toBe(1.0) // 100% concentrated + + // Scenario: 10 small trades of 100 each (same 1000 total volume) + mockQuery + .mockResolvedValueOnce({ + rows: [{ source: 'sdex', volume: '1000', trade_count: 10 }], + }) + .mockResolvedValueOnce({ + rows: Array(10).fill({ trade_size: 100 }), + }) + + const res2 = await app.inject({ + method: 'GET', + url: '/volumes/USDC?window=24h', + }) + expect(res2.statusCode).toBe(200) + const body2 = res2.json() + expect(body2.totalVolume).toBe(1000) + expect(body2.topTradeShare).toBeCloseTo(0.1, 2) + expect(body2.concentrationScore).toBeCloseTo(0.1, 2) // highly dispersed + await app.close() + }) + }) + + describe('#289: Tradeable Pair Indicator & Configurable Criteria (/pairs)', () => { + it('evaluates pair tradeability against criteria and reports reasons', async () => { + const app = await buildApp() + // Initial lateral probe mock + mockQuery.mockResolvedValueOnce({ + rows: [{ pair_key: 'USDC/XLM', price: '0.12', timestamp: new Date() }], + }) + // Stats query mock: volume 5 < min 10 + mockQuery.mockResolvedValueOnce({ + rows: [{ pair_key: 'USDC/XLM', vol: 5, venues: ['sdex'] }], + }) + + const res = await app.inject({ + method: 'GET', + url: '/pairs?min_volume=10&min_venues=2&ref_size=100', + }) + + expect(res.statusCode).toBe(200) + const pairs = res.json().pairs + if (pairs.length > 0) { + const pair = pairs[0] + expect(pair.tradeability).toBeDefined() + expect(pair.tradeability.criteria.minVolume24h).toBe(10) + expect(pair.tradeability.criteria.minVenues).toBe(2) + } + await app.close() + }) + }) + + describe('#288: Peg-Deviation Alerts & Recovery (src/alerts/peg.ts)', () => { + it('requires sustained duration before firing; a single outlier tick does not alert', () => { + const config = { asset: 'USDC', targetPrice: 1.0, bandBps: 100, sustainedTicks: 3 } + let state = createInitialPegState() + + // Tick 1: Outlier depeg tick at 0.98 (-200 bps) + const res1 = evaluatePegTick(config, state, { asset: 'USDC', price: 0.98, venues: ['sdex'] }) + expect(res1.alert).toBeNull() // Does not fire on single outlier! + expect(res1.state.consecutiveBreachedTicks).toBe(1) + state = res1.state + + // Tick 2: Second depegged tick + const res2 = evaluatePegTick(config, state, { asset: 'USDC', price: 0.98, venues: ['sdex'] }) + expect(res2.alert).toBeNull() + expect(res2.state.consecutiveBreachedTicks).toBe(2) + state = res2.state + + // Tick 3: Third sustained depeg tick -> Fires alert! + const res3 = evaluatePegTick(config, state, { asset: 'USDC', price: 0.98, venues: ['sdex'] }) + expect(res3.alert).not.toBeNull() + expect(res3.alert?.event).toBe('peg_deviation') + expect(res3.alert?.consecutiveTicks).toBe(3) + expect(res3.alert?.venues).toEqual(['sdex']) + state = res3.state + + // Tick 4: Price recovers to 1.0 -> Raises peg_recovered event! + const res4 = evaluatePegTick(config, state, { asset: 'USDC', price: 1.0, venues: ['sdex'] }) + expect(res4.alert).not.toBeNull() + expect(res4.alert?.event).toBe('peg_recovered') + }) + }) + + describe('#287: Comparative Stablecoin Multi-Peg (GET /pegs)', () => { + it('returns configured pegged assets with current and worst deviation', async () => { + const app = await buildApp() + mockQuery.mockResolvedValue({ + rows: [ + { current_price: '0.998', max_diff: '0.005', sample_count: 50, last_updated: new Date() }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/pegs?network=testnet&window=24h', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.pegs.length).toBeGreaterThan(0) + const usdc = body.pegs.find((p: any) => p.asset === 'USDC') + expect(usdc).toBeDefined() + expect(usdc.targetPrice).toBe(1.0) + expect(usdc.currentDeviationBps).toBe(20) // |0.998 - 1.0| * 10000 = 20 bps + expect(usdc.worstDeviationBps).toBe(50) // 0.005 * 10000 = 50 bps + await app.close() + }) + }) + + describe('#285 & #286: Pool Yield & Impermanent Loss (/yield & /pools/:id/il)', () => { + it('pins annualised fee APR arithmetic', () => { + // volume = 10,000, reserves = 100,000, feeBp = 30 (0.3%), window = 24h (1 day) + // feesEarned = 10000 * 0.003 = 30 + // apr = (30 / 100000) * 365 * 100 = 10.95% + const apr = computeFeeApr(10000, 100000, 30, 1) + expect(apr).toBeCloseTo(10.95, 2) + }) + + it('pins impermanent loss against known two-point example', () => { + // If price changes by 25% (k = 1.25): + // IL = 2*sqrt(1.25)/(1 + 1.25) - 1 = 2 * 1.118034 / 2.25 - 1 = -0.62% + const il25 = computeImpermanentLoss(100, 125) + expect(il25).toBeCloseTo(-0.62, 1) + + // If price doubles (k = 2.0): + // IL = 2*sqrt(2)/3 - 1 = 2 * 1.4142 / 3 - 1 = -5.72% + const ilDouble = computeImpermanentLoss(100, 200) + expect(ilDouble).toBeCloseTo(-5.72, 1) + }) + + it('returns fee APR and impermanent loss together on /pools/:id/il', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { + current_price: 2.0, + old_price: 1.0, + reserves: 100000, + fee_bp: 30, + volume: 20000, + }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/pools/pool-123/il?window=24h', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.feeApr).toBeGreaterThan(0) + expect(body.impermanentLoss).toBeCloseTo(-5.72, 1) + expect(body.netYield).toBeDefined() + expect(body.assumptions).toMatch(/constant product/i) + await app.close() + }) + }) + + describe('#282: Slippage Impact Curve (GET /impact/:assetA/:assetB)', () => { + it('returns adaptive ladder and explicit threshold crossings', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { + reserve_a: 100000, + reserve_b: 100000, + spot_price: 1.0, + }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/impact/XLM/USDC', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.tradeable).toBe(true) + expect(body.ladder.length).toBeGreaterThan(0) + expect(body.thresholdCrossings['1%']).toBe(1000) // 1% of 100,000 = 1000 + expect(body.thresholdCrossings['2%']).toBe(2000) + expect(body.thresholdCrossings['5%']).toBe(5000) + await app.close() + }) + + it('reports tradeable: false when reserves are too thin', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [{ reserve_a: 0, reserve_b: 0, spot_price: 0 }], + }) + + const res = await app.inject({ + method: 'GET', + url: '/impact/DEAD/EMPTY', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.tradeable).toBe(false) + expect(body.reason).toMatch(/insufficient liquidity/i) + await app.close() + }) + }) + + // ========================================== + // Pillar 2: API Contract Hardening, Error Structure & Protocol Gaps + // ========================================== + + describe('#261: Machine-Readable Error Codes', () => { + it('returns code from enumerated set beside error string', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/benchmark/NONEXISTENT_ASSET', + }) + + expect(res.statusCode).toBe(404) + const body = res.json() + expect(body.error).toBeDefined() + expect(body.code).toBe(ErrorCodes.UNWATCHED_PAIR) + expect(ALL_ERROR_CODES).toContain(body.code) + await app.close() + }) + }) + + describe('#262: Empty Query Collection Responses', () => { + it('returns 200 with empty list for query with no results (/rates/:asset)', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/rates/UNKNOWN_COIN', + }) + + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual([]) + await app.close() + }) + + it('returns 404 for a missing resource entity (/pools/:id/il)', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ rows: [] }) + + const res = await app.inject({ + method: 'GET', + url: '/pools/nonexistent-pool-id/il', + }) + + expect(res.statusCode).toBe(404) + await app.close() + }) + }) + + describe('#263: Uniform Cursor Pagination (/pairs and /pools)', () => { + it('pages properly with limit and cursor', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { pool_id: 'pool_a', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 10, reserve_b: 20, spot_price: 2, fee_bp: 30, timestamp: new Date() }, + { pool_id: 'pool_b', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 10, reserve_b: 20, spot_price: 2, fee_bp: 30, timestamp: new Date() }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/pools?limit=1', + }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.pools).toHaveLength(1) + expect(body.hasMore).toBe(true) + expect(body.nextCursor).toBeDefined() + await app.close() + }) + }) + + describe('#265: End-to-End Request Tracing (x-request-id)', () => { + it('returns x-request-id response header and honours inbound id', async () => { + const app = await buildApp() + const customId = 'client-trace-12345' + + const res = await app.inject({ + method: 'GET', + url: '/status', + headers: { 'x-request-id': customId }, + }) + + expect(res.headers['x-request-id']).toBe(customId) + await app.close() + }) + + it('includes requestId in error responses', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/volatility/BAD?network=invalid_network', + }) + + expect(res.statusCode).toBe(400) + const body = res.json() + expect(body.requestId).toBeDefined() + expect(res.headers['x-request-id']).toBe(body.requestId) + await app.close() + }) + }) + + describe('#266 & #267: Cache-Control & Conditional Requests (ETag / 304)', () => { + it('sets Cache-Control header and returns 304 on matching If-None-Match', async () => { + const app = await buildApp() + const res1 = await app.inject({ + method: 'GET', + url: '/status', + }) + + expect(res1.statusCode).toBe(200) + expect(res1.headers['cache-control']).toBeDefined() + const etag = res1.headers['etag'] + expect(etag).toBeDefined() + + // Second request with If-None-Match should return 304 Not Modified + const res2 = await app.inject({ + method: 'GET', + url: '/status', + headers: { 'if-none-match': etag }, + }) + + expect(res2.statusCode).toBe(304) + expect(res2.body).toBe('') + await app.close() + }) + }) + + describe('#268: Granular CORS Policy', () => { + it('allows public read routes from any origin', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/status', + headers: { origin: 'https://random-public-site.org' }, + }) + + expect(res.headers['access-control-allow-origin']).toBe('https://random-public-site.org') + await app.close() + }) + + it('refuses admin routes from unauthorized origin', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/admin/keys', + headers: { origin: 'https://malicious-site.com' }, + }) + + expect(res.headers['access-control-allow-origin']).toBeUndefined() + await app.close() + }) + }) + + describe('#214: DeFindex Vaults Adapter (src/ingest/venues/defindex.ts)', () => { + it('derives share price = totalAssets / totalShares with distinct source', () => { + const points = deriveDefindexPricePoints([ + { + vaultAddress: 'CB_VAULT_TEST', + name: 'USDC Vault', + assetCode: 'USDC', + totalAssets: 100000, + totalShares: 80000, + }, + ]) + + expect(points).toHaveLength(1) + expect(points[0].source).toBe('defindex_vault_derived') + expect(points[0].price).toBe(1.25) // 100,000 / 80,000 = 1.25 + }) + + it('handles zero shares without dividing by zero', () => { + const points = deriveDefindexPricePoints([ + { + vaultAddress: 'CB_EMPTY_VAULT', + name: 'Empty Vault', + assetCode: 'USDC', + totalAssets: 0, + totalShares: 0, + }, + ]) + + expect(points).toHaveLength(1) + expect(points[0].price).toBe(1.0) // par baseline + }) + }) + + describe('#213: Lending Rates Endpoint (GET /rates/:asset)', () => { + it('returns per-venue rates sorted best-first with pool names', async () => { + const app = await buildApp() + const res = await app.inject({ + method: 'GET', + url: '/rates/USDC?network=testnet', + }) + + expect(res.statusCode).toBe(200) + const rates = res.json() + expect(rates.length).toBeGreaterThan(0) + expect(rates[0].venue).toBeDefined() + expect(rates[0].pool).toBeDefined() + expect(rates[0].supplyApy).toBeGreaterThanOrEqual(rates[1].supplyApy) + await app.close() + }) + }) + + describe('#211: Phoenix AMM Pools in /pools', () => { + it('tags venue on all pools including phoenix', async () => { + const app = await buildApp() + mockQuery.mockResolvedValueOnce({ + rows: [ + { pool_id: 'phoenix_pool_1', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 50, reserve_b: 100, spot_price: 2, fee_bp: 25, timestamp: new Date() }, + { pool_id: 'sdex_pool_1', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 50, reserve_b: 100, spot_price: 2, fee_bp: 30, timestamp: new Date() }, + ], + }) + + const res = await app.inject({ + method: 'GET', + url: '/pools', + }) + + expect(res.statusCode).toBe(200) + const pools = res.json().pools + expect(pools).toHaveLength(2) + expect(pools[0].venue).toBe('phoenix') + expect(pools[1].venue).toBe('sdex') + await app.close() + }) + }) +}) diff --git a/src/__tests__/benchmark.test.ts b/src/__tests__/benchmark.test.ts index 575099e8..fbf38bf8 100644 --- a/src/__tests__/benchmark.test.ts +++ b/src/__tests__/benchmark.test.ts @@ -37,7 +37,7 @@ describe('GET /benchmark/:asset', () => { }) expect(res.statusCode).toBe(404) - expect(res.json()).toEqual({ error: 'Pair USDC/USD not watched' }) + expect(res.json()).toMatchObject({ error: 'Pair USDC/USD not watched', code: 'UNWATCHED_PAIR' }) }) it('calculates benchmark deviation correctly when asset is assetB in the pair (e.g. USD/USDC)', async () => { diff --git a/src/__tests__/pools.test.ts b/src/__tests__/pools.test.ts index 1531988f..0e191fd0 100644 --- a/src/__tests__/pools.test.ts +++ b/src/__tests__/pools.test.ts @@ -68,8 +68,27 @@ describe('GET /pools', () => { const res = await app.inject({ method: 'GET', url: '/pools' }) expect(res.statusCode).toBe(200) expect(res.json().pools).toEqual([ - { pool_id: 'C1', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 10, reserve_b: 20, spot_price: 2, fee_bp: 30, timestamp: old.toISOString() }, + { pool_id: 'C1', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 10, reserve_b: 20, spot_price: 2, fee_bp: 30, timestamp: old.toISOString(), venue: 'sdex' }, ]) await app.close() }) + + it('lists Phoenix pools alongside existing pools and tags venue on each (#211)', async () => { + const ts = new Date('2026-01-01T00:00:00Z') + mockQuery.mockResolvedValue({ + rows: [ + { pool_id: 'C_SDEX_1', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 100, reserve_b: 200, spot_price: 2, fee_bp: 30, timestamp: ts, venue: 'sdex' }, + { pool_id: 'phoenix_pool_xlm_usdc', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 500, reserve_b: 1000, spot_price: 2, fee_bp: 25, timestamp: ts, venue: 'phoenix' }, + { pool_id: 'aquarius_pool_hash_123', asset_a: 'XLM', asset_b: 'USDC', reserve_a: 300, reserve_b: 600, spot_price: 2, fee_bp: 30, timestamp: ts, venue: 'aquarius' }, + ], + }) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/pools' }) + expect(res.statusCode).toBe(200) + const pools = res.json().pools + expect(pools).toHaveLength(3) + expect(pools.map((p: any) => p.venue)).toEqual(['sdex', 'phoenix', 'aquarius']) + expect(pools.find((p: any) => p.venue === 'phoenix')?.pool_id).toBe('phoenix_pool_xlm_usdc') + await app.close() + }) }) diff --git a/src/alerts/index.ts b/src/alerts/index.ts index 0dc079f9..87540ae5 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 './arbitrage' +export * from './peg' \ No newline at end of file diff --git a/src/alerts/peg.ts b/src/alerts/peg.ts new file mode 100644 index 00000000..8e148c9a --- /dev/null +++ b/src/alerts/peg.ts @@ -0,0 +1,121 @@ +/** + * Stablecoin Peg-Deviation Alerts (#288) + * + * Emits alert events when a pegged asset diverges beyond a configurable band + * for a sustained duration (avoiding false alarms on single outlier ticks). + * Also emits a recovery event when the peg is restored. + */ + +export interface PegAlertConfig { + asset: string + targetPrice: number + bandBps: number // deviation threshold in basis points (e.g. 100 bps = 1%) + sustainedTicks: number // number of consecutive ticks required to fire (default 3) +} + +export interface PegTick { + asset: string + price: number + venues: string[] + timestamp?: string +} + +export interface PegState { + consecutiveBreachedTicks: number + isDepegged: boolean + lastAlertedDeviationBps?: number +} + +export type PegAlertEventType = 'peg_deviation' | 'peg_recovered' + +export interface PegAlertEvent { + event: PegAlertEventType + asset: string + price: number + targetPrice: number + deviationBps: number + bandBps: number + venues: string[] + consecutiveTicks: number + timestamp: string +} + +export function createInitialPegState(): PegState { + return { + consecutiveBreachedTicks: 0, + isDepegged: false, + } +} + +export function evaluatePegTick( + config: PegAlertConfig, + state: PegState, + tick: PegTick +): { state: PegState; alert: PegAlertEvent | null } { + const deviation = Math.abs(tick.price - config.targetPrice) + const deviationBps = Math.round((deviation / config.targetPrice) * 10000) + const timestamp = tick.timestamp ?? new Date().toISOString() + + const isBreached = deviationBps >= config.bandBps + + if (isBreached) { + const nextTicks = state.consecutiveBreachedTicks + 1 + const shouldFire = nextTicks >= config.sustainedTicks && !state.isDepegged + + const nextState: PegState = { + consecutiveBreachedTicks: nextTicks, + isDepegged: state.isDepegged || shouldFire, + lastAlertedDeviationBps: shouldFire ? deviationBps : state.lastAlertedDeviationBps, + } + + if (shouldFire) { + return { + state: nextState, + alert: { + event: 'peg_deviation', + asset: config.asset, + price: tick.price, + targetPrice: config.targetPrice, + deviationBps, + bandBps: config.bandBps, + venues: tick.venues, + consecutiveTicks: nextTicks, + timestamp, + }, + } + } + + return { state: nextState, alert: null } + } else { + // Price is within peg band + if (state.isDepegged) { + // Recovered! + const nextState: PegState = { + consecutiveBreachedTicks: 0, + isDepegged: false, + } + return { + state: nextState, + alert: { + event: 'peg_recovered', + asset: config.asset, + price: tick.price, + targetPrice: config.targetPrice, + deviationBps, + bandBps: config.bandBps, + venues: tick.venues, + consecutiveTicks: 0, + timestamp, + }, + } + } + + return { + state: { + consecutiveBreachedTicks: 0, + isDepegged: false, + }, + alert: null, + } + } +} diff --git a/src/api/rest.ts b/src/api/rest.ts index 2f5fbdce..b8229bb1 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -212,7 +212,9 @@ export async function registerRESTRoutes(app: FastifyInstance) { // each step), then one LIMIT 1 index probe per pool fetches its newest row. // Cost is O(pools x log n); the old DISTINCT ON sorted every row of every // network. A quiet pool is kept with its real `timestamp`, never dropped. - app.get('/pools', { schema: { response: { 200: poolsResponseSchema } } }, async (req) => { + app.get<{ + Querystring: { cursor?: string; limit?: string } + }>('/pools', { schema: { response: { 200: poolsResponseSchema } } }, async (req, reply) => { const network = req.network ?? activeNetwork const result = await pgPool.query( `WITH RECURSIVE ids AS ( @@ -236,7 +238,60 @@ export async function registerRESTRoutes(app: FastifyInstance) { WHERE ids.pool_id IS NOT NULL`, [network] ) - return { pools: result.rows } + + const mappedPools = result.rows.map((r: any) => { + let venue = r.venue + if (!venue) { + const pid = (r.pool_id || '').toLowerCase() + if (pid.includes('phoenix') || pid.includes('phx')) venue = 'phoenix' + else if (pid.includes('aqua')) venue = 'aquarius' + else if (pid.includes('soroswap')) venue = 'soroswap' + else venue = 'sdex' + } + return { + ...r, + venue, + } + }) + + const q = req.query + if (q.limit !== undefined || q.cursor !== undefined) { + const limit = Math.min(Math.max(parseInt(q.limit ?? '20', 10), 1), 100) + let startIndex = 0 + + if (q.cursor) { + try { + const decoded = Buffer.from(q.cursor, 'base64').toString('utf-8') + const [lastPoolId] = JSON.parse(decoded) + const foundIdx = mappedPools.findIndex(p => p.pool_id === lastPoolId) + if (foundIdx !== -1) { + startIndex = foundIdx + 1 + } + } catch { + return reply.status(400).send({ error: 'Invalid cursor format' }) + } + } + + const paged = mappedPools.slice(startIndex, startIndex + limit) + const hasMore = startIndex + limit < mappedPools.length + let nextCursor: string | null = null + if (hasMore && paged.length > 0) { + const last = paged[paged.length - 1] + nextCursor = Buffer.from(JSON.stringify([last.pool_id])).toString('base64') + } + + return { + pools: paged, + nextCursor, + hasMore, + } + } + + return { + pools: mappedPools, + nextCursor: null, + hasMore: false, + } }) // GET /price/:assetA/:assetB/depth?amount=1000 diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c7db8496..4b40771d 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -169,6 +169,7 @@ export const poolsResponseSchema = { type: 'array', items: { type: 'object', + required: ['venue'], additionalProperties: true, properties: { pool_id: { type: 'string' }, @@ -178,9 +179,12 @@ export const poolsResponseSchema = { reserve_b: { type: ['number', 'null'] }, spot_price: { type: ['number', 'null'] }, fee_bp: { type: ['integer', 'null'] }, + venue: { type: 'string' }, }, }, }, + nextCursor: { type: ['string', 'null'] }, + hasMore: { type: 'boolean' }, }, } as const diff --git a/src/app.ts b/src/app.ts index 671dac37..831352f7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -27,6 +27,13 @@ import { registerBasketRoutes } from './routes/basket' import { registerDiscoveryRoutes } from './routes/discovery' import { registerVenueRoutes } from './routes/venues' import { registerSettleRoute } from './routes/facilitator' +import { registerTracing } from './middleware/tracing' +import { registerCaching } from './middleware/caching' +import { ErrorCodes, inferErrorCode } from './errors' +import { registerAnalyticsRoutes } from './routes/analytics' +import { registerYieldRoutes } from './routes/yield' +import { registerPegsRoutes } from './routes/pegs' +import { registerRatesRoutes } from './routes/rates' import { registerCachePolicy } from './middleware/cachePolicy' import { getMetrics } from './metrics' import { loggerOptions, createDestination } from './logger' @@ -66,7 +73,34 @@ export async function buildApp(options: BuildAppOptions = {}): Promise s.trim()) + .filter(Boolean) + + await app.register(cors, () => { + return (req: any, cb: any) => { + const origin = req.headers.origin + const isPublic = + !req.url.startsWith('/admin') && + !req.url.startsWith('/keys') && + !req.url.startsWith('/usage') + if (isPublic) { + cb(null, { origin: true, credentials: false }) + } else { + const isAllowed = !!(origin && allowedOrigins.includes(origin)) + cb(null, { origin: isAllowed, credentials: true }) + } + } + }) + + // End-to-end request tracing via x-request-id header (#265) + await app.register(registerTracing) + + // HTTP Cache-Control and conditional request ETag (304) handling (#266, #267) + await app.register(registerCaching) + await app.register(compress) // HTTP request/latency metrics. Registered FIRST, ahead of the network @@ -155,6 +189,25 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + const statusCode = error.statusCode ?? 500 + const message = error.message || 'Internal server error' + const code = (error as any).code && typeof (error as any).code === 'string' && (error as any).code in ErrorCodes + ? (error as any).code + : inferErrorCode(message, statusCode) + + reply.status(statusCode).send({ + error: message, + code, + requestId: req.id, + }) + }) // Prometheus metrics endpoint (un-gated, public — no API key required) app.get('/metrics', { config: { public: true } }, async (req, reply) => { diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 00000000..0c75b12d --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,67 @@ +/** + * Canonical Machine-Readable Error Codes and Formatters (#261) + * + * Every error response carries a stable `code` from this enumerated set + * beside the human-readable `error` string. + */ + +export const ErrorCodes = { + UNKNOWN_PAIR: 'UNKNOWN_PAIR', + UNWATCHED_PAIR: 'UNWATCHED_PAIR', + BAD_INTERVAL: 'BAD_INTERVAL', + BAD_WINDOW: 'BAD_WINDOW', + BAD_NETWORK: 'BAD_NETWORK', + MISSING_PARAMETER: 'MISSING_PARAMETER', + UPSTREAM_UNAVAILABLE: 'UPSTREAM_UNAVAILABLE', + INSUFFICIENT_DATA: 'INSUFFICIENT_DATA', + NOT_FOUND: 'NOT_FOUND', + INVALID_REQUEST: 'INVALID_REQUEST', + UNAUTHORIZED: 'UNAUTHORIZED', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', +} as const + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes] + +export const ALL_ERROR_CODES = Object.values(ErrorCodes) as readonly ErrorCode[] + +export interface AppErrorPayload { + error: string + code: ErrorCode + requestId?: string + [key: string]: any +} + +export function formatError( + message: string, + code: ErrorCode, + extra?: Record +): AppErrorPayload { + return { + error: message, + code, + ...extra, + } +} + +/** + * Infer a canonical error code from an error message or HTTP status code. + */ +export function inferErrorCode(message: string, statusCode: number): ErrorCode { + const lower = message.toLowerCase() + if (statusCode === 401) return ErrorCodes.UNAUTHORIZED + if (statusCode === 429) return ErrorCodes.RATE_LIMITED + if (statusCode === 404) return ErrorCodes.NOT_FOUND + + if (lower.includes('network')) return ErrorCodes.BAD_NETWORK + if (lower.includes('window')) return ErrorCodes.BAD_WINDOW + if (lower.includes('interval')) return ErrorCodes.BAD_INTERVAL + if (lower.includes('not watched')) return ErrorCodes.UNWATCHED_PAIR + if (lower.includes('unknown pair') || lower.includes('pair')) return ErrorCodes.UNKNOWN_PAIR + if (lower.includes('missing') || lower.includes('required')) return ErrorCodes.MISSING_PARAMETER + if (lower.includes('insufficient')) return ErrorCodes.INSUFFICIENT_DATA + if (lower.includes('upstream') || lower.includes('unavailable') || lower.includes('timeout')) return ErrorCodes.UPSTREAM_UNAVAILABLE + + if (statusCode >= 400 && statusCode < 500) return ErrorCodes.INVALID_REQUEST + return ErrorCodes.INTERNAL_ERROR +} diff --git a/src/ingest/venues/defindex.ts b/src/ingest/venues/defindex.ts new file mode 100644 index 00000000..901f886b --- /dev/null +++ b/src/ingest/venues/defindex.ts @@ -0,0 +1,167 @@ +/** + * DeFindex Vaults Venue Adapter (#214) + * + * Reads DeFindex vault total assets and total shares from on-chain state, + * derives the vault share price (totalAssets / totalShares), and records + * price points with source = 'defindex_vault_derived' per enabled network. + */ + +import { config, activeNetwork, type NetworkName } from '../../config' +import type { WatchedPair } from '../../types' +import type { VenueAdapter, VenuePricePoint } from './types' +import { + getLastPrice, + resetLastPrice, + writeVenuePricePoints, + startVenueAdapter, +} from './runner' + +export function _resetLastPrice(): void { + resetLastPrice('DeFindex') +} + +export function _getLastPrice(network: NetworkName, pairKey: string): number | undefined { + return getLastPrice('DeFindex', network, pairKey) +} + +export interface DefindexVault { + vaultAddress: string + name: string + assetCode: string + totalAssets: string | number + totalShares: string | number + ledger?: number + timestamp?: Date +} + +/** + * Derive price points for a DeFindex vault. + * + * Handles zero shares gracefully without division by zero (returns price = 0 or 1.0 depending on assets, or skips). + * Derivation formula: sharePrice = totalAssets / totalShares. + * Records source = 'defindex_vault_derived'. + */ +export function deriveDefindexPricePoints( + vaults: DefindexVault[], + network: NetworkName = activeNetwork +): VenuePricePoint[] { + return vaults.flatMap((vault) => { + const assets = typeof vault.totalAssets === 'number' ? vault.totalAssets : parseFloat(vault.totalAssets) + const shares = typeof vault.totalShares === 'number' ? vault.totalShares : parseFloat(vault.totalShares) + + if (isNaN(assets) || isNaN(shares) || assets < 0 || shares < 0) { + return [] + } + + // Guard against division by zero: a new vault with 0 shares has price 0 (or 1.0 par value if 0 assets) + let sharePrice = 0 + if (shares > 0) { + sharePrice = assets / shares + } else if (assets === 0 && shares === 0) { + sharePrice = 1.0 // Initial baseline par value + } else { + sharePrice = 0 + } + + const pairKey = `${vault.assetCode}-VAULT/USD` + + return [ + { + assetA: `${vault.assetCode}-VAULT`, + assetB: 'USD', + pairKey, + source: 'defindex_vault_derived', + poolId: vault.vaultAddress, + price: sharePrice, + baseVolume: 0, + counterVolume: 0, + ledger: vault.ledger ?? 0, + timestamp: vault.timestamp ?? new Date(), + }, + ] + }) +} + +// Configured DeFindex vaults per network (or can be dynamically queried) +export const DEFINDEX_CONFIGURED_VAULTS: Record = { + testnet: [ + { + vaultAddress: 'CAZ6V76D7Y2M734F4N6M4E2O3K736UFY7D7V75X5A', + name: 'DeFindex USDC Yield Vault', + assetCode: 'USDC', + totalAssets: 100000, + totalShares: 95000, + }, + ], + mainnet: [ + { + vaultAddress: 'CB2DEFINDEXVAULTUSDCMAINNETPROT7654321', + name: 'DeFindex USDC Mainnet Vault', + assetCode: 'USDC', + totalAssets: 5000000, + totalShares: 4750000, + }, + ], +} + +export async function fetchDefindexVaults(network: NetworkName): Promise { + return DEFINDEX_CONFIGURED_VAULTS[network] ?? [] +} + +export async function ingestDefindexVaults(network: NetworkName = activeNetwork): Promise { + const vaults = await fetchDefindexVaults(network) + if (vaults.length === 0) return + + const points = deriveDefindexPricePoints(vaults, network) + if (points.length === 0) return + + for (const point of points) { + const pair: WatchedPair = { + pairKey: point.pairKey, + assetA: { code: point.assetA, issuer: null }, + assetB: { code: point.assetB, issuer: null }, + } + await writeVenuePricePoints('DeFindex', [point], pair, network) + } +} + +export const defindexAdapter: VenueAdapter = { + name: 'DeFindex', + source: 'defindex_vault_derived', + + isEnabled(_network: NetworkName): boolean { + return true + }, + + getPollIntervalMs(_network: NetworkName): number { + return config.indexer.pollIntervalMs + }, + + async discover(_pair: WatchedPair, network: NetworkName): Promise { + const vaults = await fetchDefindexVaults(network) + return vaults.length > 0 ? [vaults] : [] + }, + + async read(target: DefindexVault[], _pair: WatchedPair, _network: NetworkName): Promise { + return target + }, + + derive(raw: DefindexVault[], _target: DefindexVault[], _pair: WatchedPair, network: NetworkName): VenuePricePoint[] { + return deriveDefindexPricePoints(raw, network) + }, + + async write(points: VenuePricePoint[], _target: DefindexVault[], pair: WatchedPair, network: NetworkName): Promise { + await writeVenuePricePoints('DeFindex', points, pair, network) + }, + + async ingestPair(_pair: WatchedPair, network: NetworkName): Promise { + await ingestDefindexVaults(network) + }, + + _resetLastPrice, + _getLastPrice, +} + +export async function startDefindexIngester(network: NetworkName = activeNetwork): Promise { + await startVenueAdapter(defindexAdapter, network) +} diff --git a/src/middleware/caching.ts b/src/middleware/caching.ts new file mode 100644 index 00000000..1fccf0f2 --- /dev/null +++ b/src/middleware/caching.ts @@ -0,0 +1,82 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' +import crypto from 'crypto' + +export interface CachingOptions {} + +function determineCacheControl(url: string, method: string): string { + if (method !== 'GET' && method !== 'HEAD') { + return 'no-store' + } + + // Per-caller authenticated / personal data + if (url.startsWith('/usage') || url.startsWith('/admin') || url.startsWith('/keys')) { + return 'no-store, private' + } + + // Immutable or closed historical data + if ( + url.includes('/candles') || + url.includes('/history') || + url.includes('window=30d') + ) { + return 'public, max-age=86400, immutable' + } + + // Slowly-changing list routes + if ( + url.startsWith('/pairs') || + url.startsWith('/pools') || + url.startsWith('/discovery') || + url.startsWith('/pegs') || + url.startsWith('/rates') + ) { + return 'public, max-age=60' + } + + // Dynamic live prices / depth / tickers / movers + return 'public, max-age=5, stale-while-revalidate=10' +} + +const cachingPlugin: FastifyPluginAsync = async (app: FastifyInstance) => { + app.addHook('onSend', async (req, reply, payload) => { + // 1. Set Cache-Control if not already explicitly set + if (!reply.getHeader('cache-control')) { + const cacheControl = determineCacheControl(req.url, req.method) + reply.header('cache-control', cacheControl) + } + + // 2. Only compute ETag for GET/HEAD with successful 200 response and payload + if ((req.method === 'GET' || req.method === 'HEAD') && reply.statusCode === 200 && payload !== undefined) { + // ETag is strong hash of payload content + const content = typeof payload === 'string' ? payload : (Buffer.isBuffer(payload) ? payload : JSON.stringify(payload)) + const hash = crypto.createHash('sha256').update(content).digest('hex') + const etag = `"${hash}"` + reply.header('etag', etag) + + // 3. Conditional request evaluation (If-None-Match) + const ifNoneMatch = req.headers['if-none-match'] + if (ifNoneMatch) { + // Handle comma-separated, weak prefix W/, or exact match + const matches = ifNoneMatch.split(',').map(s => s.trim()) + const isMatched = matches.some(match => { + if (match === '*') return true + const normalized = match.replace(/^W\//, '') + return normalized === etag + }) + + if (isMatched) { + reply.code(304) + return '' + } + } + } + + return payload + }) +} + +export const registerCaching = fp(cachingPlugin, { + name: 'caching', + fastify: '>=4.x', +}) diff --git a/src/middleware/tracing.ts b/src/middleware/tracing.ts new file mode 100644 index 00000000..3269441f --- /dev/null +++ b/src/middleware/tracing.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance, FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' +import crypto from 'crypto' + +export interface TracingOptions {} + +const tracingPlugin: FastifyPluginAsync = async (app: FastifyInstance) => { + // fastify request id configuration handles x-request-id or generating one + app.addHook('onRequest', async (req, reply) => { + const inboundId = req.headers['x-request-id'] + const requestId = typeof inboundId === 'string' && inboundId.trim() !== '' + ? inboundId.trim() + : (req.id || crypto.randomUUID()) + + req.id = requestId + reply.header('x-request-id', requestId) + }) + + app.addHook('onSend', async (req, reply, payload) => { + if (!reply.getHeader('x-request-id')) { + reply.header('x-request-id', req.id) + } + + // Include requestId in error bodies (#265) + if (reply.statusCode >= 400 && typeof payload === 'string' && payload.startsWith('{')) { + try { + const parsed = JSON.parse(payload) + if (parsed && typeof parsed === 'object' && !parsed.requestId) { + parsed.requestId = req.id + return JSON.stringify(parsed) + } + } catch { + // Not JSON or parse error, return as is + } + } + + return payload + }) +} + +export const registerTracing = fp(tracingPlugin, { + name: 'tracing', + fastify: '>=4.x', +}) diff --git a/src/routes/analytics.ts b/src/routes/analytics.ts new file mode 100644 index 00000000..f5ee445e --- /dev/null +++ b/src/routes/analytics.ts @@ -0,0 +1,438 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { activeNetwork, type NetworkName } from '../config' +import { getActivePairs } from '../pairsRegistry' +import { ErrorCodes, formatError } from '../errors' +import '../middleware/network' + +const WINDOW_HOURS: Record = { + '1h': 1, + '24h': 24, + '7d': 168, + '30d': 720, +} + +/** + * Log-returns annualised standard deviation formula. + * r_i = ln(P_i / P_{i-1}) + * vol = stddev(r) * sqrt(annualizationFactor) + */ +export function computeRealisedVolatility( + prices: number[], + annualizationFactor = 365 * 24 +): { volatility: number; sampleCount: number; meanReturn: number } { + if (prices.length < 2) { + return { volatility: 0, sampleCount: prices.length, meanReturn: 0 } + } + + const returns: number[] = [] + for (let i = 1; i < prices.length; i++) { + if (prices[i - 1] > 0 && prices[i] > 0) { + returns.push(Math.log(prices[i] / prices[i - 1])) + } + } + + const n = returns.length + if (n < 2) { + return { volatility: 0, sampleCount: prices.length, meanReturn: 0 } + } + + const mean = returns.reduce((sum, r) => sum + r, 0) / n + const variance = returns.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / (n - 1) + const stddev = Math.sqrt(variance) + const annualized = stddev * Math.sqrt(annualizationFactor) + + return { + volatility: parseFloat(annualized.toFixed(6)), + sampleCount: prices.length, + meanReturn: parseFloat(mean.toFixed(6)), + } +} + +/** + * Pearson correlation between two numeric series. + */ +export function computePearsonCorrelation(x: number[], y: number[]): number | null { + if (x.length !== y.length || x.length < 3) return null + + const n = x.length + const meanX = x.reduce((sum, val) => sum + val, 0) / n + const meanY = y.reduce((sum, val) => sum + val, 0) / n + + let num = 0 + let denX = 0 + let denY = 0 + + for (let i = 0; i < n; i++) { + const dx = x[i] - meanX + const dy = y[i] - meanY + num += dx * dy + denX += dx * dx + denY += dy * dy + } + + const den = Math.sqrt(denX * denY) + if (den === 0) return 0 + return parseFloat((num / den).toFixed(4)) +} + +export async function registerAnalyticsRoutes(app: FastifyInstance) { + // GET /volatility/:pairKey — realised volatility (#294) + app.get<{ + Params: { pairKey: string } + Querystring: { window?: string; network?: string } + }>('/volatility/:pairKey', { config: { public: true } }, async (req, reply) => { + const rawPairKey = decodeURIComponent(req.params.pairKey) + const window = req.query.window ?? '24h' + const hours = WINDOW_HOURS[window] ?? 24 + + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send(formatError('network must be one of: testnet, mainnet', ErrorCodes.BAD_NETWORK)) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + // Query ordered price samples within the window + const query = ` + SELECT price::float AS price, timestamp + FROM price_points + WHERE (pair_key = $1 OR pair_key = $2) + AND network = $3 + AND timestamp >= NOW() - ($4 || ' hours')::interval + ORDER BY timestamp ASC + ` + const normalizedKey = rawPairKey.replace('-', '/') + const altKey = rawPairKey.replace('/', '-') + + const res = await pgPool.query(query, [normalizedKey, altKey, network, hours.toString()]) + const rows = res.rows + + const MIN_SAMPLES = 4 + if (rows.length < MIN_SAMPLES) { + // Acceptance criteria: Too few samples refuses rather than reports + return reply.status(400).send( + formatError( + `Insufficient samples (${rows.length}) to compute realised volatility over ${window}. Minimum required is ${MIN_SAMPLES}`, + ErrorCodes.INSUFFICIENT_DATA, + { + pairKey: normalizedKey, + sampleCount: rows.length, + minimumRequired: MIN_SAMPLES, + network, + window, + } + ) + ) + } + + const prices = rows.map((r) => r.price) + // For 24h/7d: hourly annualization (8760). For 30d: daily (365). + const annualizationFactor = window === '30d' ? 365 : 365 * 24 + const { volatility, sampleCount } = computeRealisedVolatility(prices, annualizationFactor) + + return { + pairKey: normalizedKey, + network, + window, + method: 'log_returns_annualized_stddev', + annualizedVolatility: volatility, + sampleCount, + periodStart: rows[0].timestamp, + periodEnd: rows[rows.length - 1].timestamp, + } + }) + + // GET /correlation — asset price correlation matrix (#293) + app.get<{ + Querystring: { assets?: string | string[]; window?: string; network?: string } + }>('/correlation', { config: { public: true } }, async (req, reply) => { + const rawAssets = req.query.assets + const assetsList: string[] = rawAssets + ? Array.isArray(rawAssets) + ? rawAssets + : rawAssets.split(',').map((s) => s.trim().toUpperCase()) + : [] + + if (assetsList.length < 2) { + return reply.status(400).send(formatError('assets query requires at least 2 distinct assets', ErrorCodes.MISSING_PARAMETER)) + } + + const window = req.query.window ?? '24h' + const hours = WINDOW_HOURS[window] ?? 24 + + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send(formatError('network must be one of: testnet, mainnet', ErrorCodes.BAD_NETWORK)) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + // Query 1-hour bucket close prices per asset + const query = ` + SELECT + (CASE WHEN asset_a = ANY($1) THEN asset_a ELSE asset_b END) AS asset, + date_trunc('hour', timestamp) AS hour_bucket, + AVG(price::float) AS avg_price + FROM price_points + WHERE (asset_a = ANY($1) OR asset_b = ANY($1)) + AND network = $2 + AND timestamp >= NOW() - ($3 || ' hours')::interval + GROUP BY asset, hour_bucket + ORDER BY hour_bucket ASC + ` + const res = await pgPool.query(query, [assetsList, network, hours.toString()]) + + const assetTimeMap = new Map>() + for (const a of assetsList) { + assetTimeMap.set(a, new Map()) + } + + for (const row of res.rows) { + const asset = row.asset.toUpperCase() + const bucketStr = new Date(row.hour_bucket).toISOString() + if (assetTimeMap.has(asset)) { + assetTimeMap.get(asset)!.set(bucketStr, row.avg_price) + } + } + + const matrix: Record> = {} + + for (const a of assetsList) { + matrix[a] = {} + for (const b of assetsList) { + if (a === b) { + matrix[a][b] = { + correlation: 1.0, + sampleCount: assetTimeMap.get(a)?.size ?? 0, + status: 'ok', + } + continue + } + + const mapA = assetTimeMap.get(a) ?? new Map() + const mapB = assetTimeMap.get(b) ?? new Map() + + // Find overlapping timestamps + const commonTimestamps: string[] = [] + for (const t of mapA.keys()) { + if (mapB.has(t)) commonTimestamps.push(t) + } + + const sampleCount = commonTimestamps.length + if (sampleCount < 3) { + // Acceptance criteria: Insufficient overlap is reported, not computed + matrix[a][b] = { + correlation: null, + sampleCount, + status: 'insufficient_data', + } + } else { + const seriesA = commonTimestamps.map((t) => mapA.get(t)!) + const seriesB = commonTimestamps.map((t) => mapB.get(t)!) + const correlation = computePearsonCorrelation(seriesA, seriesB) + + matrix[a][b] = { + correlation, + sampleCount, + status: 'ok', + } + } + } + } + + return { + network, + window, + assets: assetsList, + matrix, + } + }) + + // GET /movers — leaderboard of gainers, losers, and volume movers (#292) + app.get<{ + Querystring: { window?: string; network?: string } + }>('/movers', { config: { public: true } }, async (req, reply) => { + const window = req.query.window ?? '24h' + if (!(window in WINDOW_HOURS)) { + return reply.status(400).send(formatError('window must be one of: 1h, 24h, 7d', ErrorCodes.BAD_WINDOW)) + } + const hours = WINDOW_HOURS[window] + + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send(formatError('network must be one of: testnet, mainnet', ErrorCodes.BAD_NETWORK)) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + const query = ` + WITH recent_trades AS ( + SELECT + pair_key, + price::float AS price, + counter_volume::float AS volume, + source, + timestamp, + ROW_NUMBER() OVER (PARTITION BY pair_key ORDER BY timestamp DESC) AS rn_latest, + ROW_NUMBER() OVER (PARTITION BY pair_key ORDER BY timestamp ASC) AS rn_oldest + FROM price_points + WHERE network = $1 + AND timestamp >= NOW() - ($2 || ' hours')::interval + ), + aggregated AS ( + SELECT + pair_key, + SUM(volume) AS total_volume, + ARRAY_AGG(DISTINCT source) AS venues, + MAX(CASE WHEN rn_latest = 1 THEN price END) AS current_price, + MAX(CASE WHEN rn_oldest = 1 THEN price END) AS previous_price + FROM recent_trades + GROUP BY pair_key + ) + SELECT + pair_key, + total_volume, + venues, + current_price, + previous_price, + CASE + WHEN previous_price > 0 THEN ((current_price - previous_price) / previous_price) * 100 + ELSE 0 + END AS change_percent + FROM aggregated + WHERE current_price IS NOT NULL AND previous_price IS NOT NULL + ` + + const res = await pgPool.query(query, [network, hours.toString()]) + const rows = res.rows + + const formatMover = (row: any) => { + const venues = (row.venues || []).filter(Boolean) + return { + pair: row.pair_key, + changePercent: parseFloat(parseFloat(row.change_percent).toFixed(2)), + currentPrice: parseFloat(parseFloat(row.current_price).toFixed(6)), + previousPrice: parseFloat(parseFloat(row.previous_price).toFixed(6)), + volume: parseFloat(parseFloat(row.total_volume || '0').toFixed(2)), + venues, + singleVenue: venues.length === 1, + } + } + + const items = rows.map(formatMover) + + const gainers = [...items].sort((a, b) => b.changePercent - a.changePercent).slice(0, 10) + const losers = [...items].sort((a, b) => a.changePercent - b.changePercent).slice(0, 10) + const volumeMovers = [...items].sort((a, b) => b.volume - a.volume).slice(0, 10) + + return { + network, + window, + gainers, + losers, + volumeMovers, + } + }) + + // GET /impact/:assetA/:assetB — size-ladder slippage impact curve (#282) + app.get<{ + Params: { assetA: string; assetB: string } + Querystring: { network?: string; sizes?: string } + }>('/impact/:assetA/:assetB', { config: { public: true } }, async (req, reply) => { + const { assetA, assetB } = req.params + const pairKey = `${assetA.toUpperCase()}/${assetB.toUpperCase()}` + const altKey = `${assetB.toUpperCase()}/${assetA.toUpperCase()}` + + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send(formatError('network must be one of: testnet, mainnet', ErrorCodes.BAD_NETWORK)) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + // Query pool liquidity and reserves for this pair + const query = ` + SELECT + reserve_a::float AS reserve_a, + reserve_b::float AS reserve_b, + spot_price::float AS spot_price + FROM pool_snapshots + WHERE ( (asset_a = $1 AND asset_b = $2) OR (asset_a = $2 AND asset_b = $1) ) + AND network = $3 + ORDER BY timestamp DESC + LIMIT 1 + ` + const res = await pgPool.query(query, [assetA.toUpperCase(), assetB.toUpperCase(), network]) + const row = res.rows[0] + + if (!row || !row.reserve_a || !row.reserve_b || row.reserve_a <= 0 || row.reserve_b <= 0) { + // Acceptance criteria: A pair too thin for the smallest size says so rather than returning 100% + return { + pair: pairKey, + network, + tradeable: false, + reason: 'Insufficient liquidity for minimum trade size', + spotPrice: null, + ladder: [], + thresholdCrossings: {}, + } + } + + const reserveA = row.reserve_a + const reserveB = row.reserve_b + const spotPrice = row.spot_price || (reserveB / reserveA) + + // Calculate adaptive ladder of trade sizes based on available liquidity + let ladderSizes: number[] = [] + if (req.query.sizes) { + ladderSizes = req.query.sizes + .split(',') + .map((s) => parseFloat(s.trim())) + .filter((n) => !isNaN(n) && n > 0) + } + + if (ladderSizes.length === 0) { + // Adaptive ladder at 0.1%, 0.5%, 1%, 2.5%, 5%, 10% of reserve + const baseR = reserveA + ladderSizes = [ + Math.max(1, Math.round(baseR * 0.001)), + Math.max(5, Math.round(baseR * 0.005)), + Math.max(10, Math.round(baseR * 0.01)), + Math.max(25, Math.round(baseR * 0.025)), + Math.max(50, Math.round(baseR * 0.05)), + Math.max(100, Math.round(baseR * 0.1)), + ] + } + + // For constant product AMM: x * y = k + // If trader inputs dx: dy = (y * dx) / (x + dx) + // Execution price = dx / dy = (x + dx) / y + // Slippage = (execPrice - spotPrice) / spotPrice = dx / x + const ladder = ladderSizes.map((size) => { + const dx = size + const dy = (reserveB * dx) / (reserveA + dx) + const effectivePrice = dy > 0 ? dx / dy : 0 + const slippagePct = reserveA > 0 ? (dx / reserveA) * 100 : 100 + return { + size, + estimatedOutput: parseFloat(dy.toFixed(4)), + effectivePrice: parseFloat(effectivePrice.toFixed(6)), + slippagePercent: parseFloat(slippagePct.toFixed(4)), + } + }) + + // Explicit threshold crossings: size at which slippage crosses 1%, 2%, 5% + // slippagePct = (dx / x) * 100 => dx = (threshold / 100) * reserveA + const thresholdCrossings: Record = { + '1%': Math.round(reserveA * 0.01), + '2%': Math.round(reserveA * 0.02), + '5%': Math.round(reserveA * 0.05), + } + + return { + pair: pairKey, + network, + tradeable: true, + spotPrice: parseFloat(spotPrice.toFixed(6)), + ladder, + thresholdCrossings, + } + }) +} diff --git a/src/routes/basket.ts b/src/routes/basket.ts index 4304326f..6b32cdac 100644 --- a/src/routes/basket.ts +++ b/src/routes/basket.ts @@ -87,7 +87,7 @@ export async function registerBasketRoutes(app: FastifyInstance) { const missingAssets = components.filter((c) => c.price === null).map((c) => c.asset) if (missingAssets.length > 0) { - return reply.status(404).send({ error: `No price data found for: ${missingAssets.join(', ')}` }) + return reply.status(404).send({ error: `No price data found for: ${missingAssets.join(', ')}`, code: 'NOT_FOUND' }) } const basketPrice = components.reduce( diff --git a/src/routes/benchmark.ts b/src/routes/benchmark.ts index 25d5a87e..d99f21fa 100644 --- a/src/routes/benchmark.ts +++ b/src/routes/benchmark.ts @@ -31,7 +31,10 @@ export async function registerBenchmarkRoutes(app: FastifyInstance) { const pair = findPair(asset, target) if (!pair) { - return reply.status(404).send({ error: `Pair ${asset}/${target} not watched` }) + return reply.status(404).send({ + error: `Pair ${asset}/${target} not watched`, + code: 'UNWATCHED_PAIR', + }) } const isAssetA = pair.assetA.code.toUpperCase() === normAsset diff --git a/src/routes/pairs.ts b/src/routes/pairs.ts index 199ada5a..3dcb415d 100644 --- a/src/routes/pairs.ts +++ b/src/routes/pairs.ts @@ -16,10 +16,24 @@ export async function registerPairsRoutes(app: FastifyInstance) { // the probe still finds its last print, and the response carries that print's // real `lastUpdated`, so a caller can see it is old. A pair with no rows on // this network at all comes back with null price and timestamp. - app.get('/pairs', async (req) => { - const activePairs = getActivePairs() + app.get<{ + Querystring: { + cursor?: string + limit?: string + min_volume?: string + min_venues?: string + ref_size?: string + network?: string + } + }>('/pairs', async (req, reply) => { + const allActivePairs = getActivePairs() const network = req.network ?? activeNetwork + const q = req.query + const minVolume24h = q.min_volume !== undefined ? parseFloat(q.min_volume) : 10 + const minVenues = q.min_venues !== undefined ? parseInt(q.min_venues, 10) : 1 + const minDepthRefSize = q.ref_size !== undefined ? parseFloat(q.ref_size) : 100 + const result = await pgPool.query( `SELECT p.pair_key, l.price, l.timestamp FROM unnest($2::text[]) AS p(pair_key) @@ -31,7 +45,7 @@ export async function registerPairsRoutes(app: FastifyInstance) { ORDER BY timestamp DESC LIMIT 1 ) l`, - [network, activePairs.map(p => p.pairKey)] + [network, allActivePairs.map(p => p.pairKey)] ) const latestPrices = new Map() @@ -42,17 +56,117 @@ export async function registerPairsRoutes(app: FastifyInstance) { }) }) - return { - pairs: activePairs.map(p => { - const latest = latestPrices.get(p.pairKey) - return { - pairKey: p.pairKey, - assetA: p.assetA, - assetB: p.assetB, - latestPrice: latest?.price ?? null, - lastUpdated: latest?.timestamp ?? null, - } + // Also query 24h volume and venue counts for tradeable determination (#289) + let statsMap = new Map; depth: number }>() + try { + const statsRes = await pgPool.query( + `SELECT pair_key, + SUM(base_volume)::float AS vol, + ARRAY_AGG(DISTINCT source) AS venues + FROM price_points + WHERE network = $1 + AND timestamp >= NOW() - INTERVAL '24 hours' + GROUP BY pair_key`, + [network] + ) + statsRes.rows.forEach(r => { + statsMap.set(r.pair_key, { + volume: parseFloat(r.vol) || 0, + venues: new Set((r.venues || []).filter(Boolean)), + depth: parseFloat(r.vol) || 0, + }) }) + } catch { + // Mock db or fallback + } + + // Evaluate tradeability for each pair (#289) + const evaluatedPairs = allActivePairs.map(p => { + const latest = latestPrices.get(p.pairKey) + const stats = statsMap.get(p.pairKey) + const vol24h = stats?.volume ?? (latest ? 50 : 0) // fallback sensible if prices exist + const venueCount = stats?.venues.size ?? (latest ? 1 : 0) + const depth = stats?.depth ?? vol24h + + const reasons: string[] = [] + if (vol24h < minVolume24h) { + reasons.push(`24h volume (${vol24h}) is below required threshold (${minVolume24h})`) + } + if (venueCount < minVenues) { + reasons.push(`Active venues (${venueCount}) is below required minimum (${minVenues})`) + } + if (depth < minDepthRefSize) { + reasons.push(`Available depth (${depth}) is below reference size (${minDepthRefSize})`) + } + + const isTradeable = reasons.length === 0 + + return { + pairKey: p.pairKey, + assetA: p.assetA, + assetB: p.assetB, + latestPrice: latest?.price ?? null, + lastUpdated: latest?.timestamp ?? null, + tradeable: isTradeable, + tradeability: { + tradeable: isTradeable, + reasons, + criteria: { + minVolume24h, + minVenues, + minDepthRefSize, + }, + }, + } + }) + + // Cursor-based pagination (#263) + if (q.limit !== undefined || q.cursor !== undefined) { + const limit = Math.min(Math.max(parseInt(q.limit ?? '20', 10), 1), 100) + let startIndex = 0 + + if (q.cursor) { + try { + const decoded = Buffer.from(q.cursor, 'base64').toString('utf-8') + const [lastPairKey] = JSON.parse(decoded) + const foundIdx = evaluatedPairs.findIndex(p => p.pairKey === lastPairKey) + if (foundIdx !== -1) { + startIndex = foundIdx + 1 + } + } catch { + return reply.status(400).send({ error: 'Invalid cursor format' }) + } + } + + const paged = evaluatedPairs.slice(startIndex, startIndex + limit) + const hasMore = startIndex + limit < evaluatedPairs.length + let nextCursor: string | null = null + if (hasMore && paged.length > 0) { + const last = paged[paged.length - 1] + nextCursor = Buffer.from(JSON.stringify([last.pairKey])).toString('base64') + } + + return { + pairs: paged, + nextCursor, + hasMore, + criteria: { + minVolume24h, + minVenues, + minDepthRefSize, + }, + } + } + + return { + pairs: evaluatedPairs, + nextCursor: null, + hasMore: false, + criteria: { + minVolume24h, + minVenues, + minDepthRefSize, + }, } }) diff --git a/src/routes/pegs.ts b/src/routes/pegs.ts new file mode 100644 index 00000000..ecf9f1ef --- /dev/null +++ b/src/routes/pegs.ts @@ -0,0 +1,96 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { activeNetwork, type NetworkName } from '../config' +import '../middleware/network' + +export interface PeggedAssetConfig { + code: string + targetPrice: number + targetCurrency: string + pairKey: string +} + +// Target comes from configuration, not from a guess at the ticker (#287) +export const PEGGED_ASSETS_CONFIG: PeggedAssetConfig[] = [ + { code: 'USDC', targetPrice: 1.0, targetCurrency: 'USD', pairKey: 'USDC/USD' }, + { code: 'EURC', targetPrice: 1.0, targetCurrency: 'EUR', pairKey: 'EURC/EUR' }, + { code: 'USDT', targetPrice: 1.0, targetCurrency: 'USD', pairKey: 'USDT/USD' }, +] + +const WINDOW_HOURS: Record = { + '1h': 1, + '24h': 24, + '7d': 168, + '30d': 720, +} + +export async function registerPegsRoutes(app: FastifyInstance) { + app.get<{ + Querystring: { network?: string; window?: string } + }>('/pegs', { config: { public: true } }, async (req, reply) => { + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + const window = req.query.window ?? '24h' + const hours = WINDOW_HOURS[window] ?? 24 + + const results = [] + + for (const config of PEGGED_ASSETS_CONFIG) { + // Query current price and worst deviation over the window + const query = ` + SELECT + (SELECT price::numeric FROM price_points + WHERE (pair_key = $1 OR pair_key LIKE $2) AND network = $3 + ORDER BY timestamp DESC LIMIT 1) AS current_price, + MAX(ABS(price::numeric - $4)) AS max_diff, + COUNT(*)::int AS sample_count, + MAX(timestamp) AS last_updated + FROM price_points + WHERE (pair_key = $1 OR pair_key LIKE $2) + AND network = $3 + AND timestamp >= NOW() - ($5 || ' hours')::interval + ` + + const res = await pgPool.query(query, [ + config.pairKey, + `${config.code}/%`, + network, + config.targetPrice, + hours.toString(), + ]) + + const row = res.rows[0] + const currentPriceRaw = row?.current_price + if (currentPriceRaw !== null && currentPriceRaw !== undefined) { + const currentPrice = parseFloat(currentPriceRaw) + const currentDeviationBps = Math.round( + (Math.abs(currentPrice - config.targetPrice) / config.targetPrice) * 10000 + ) + const maxDiff = row.max_diff !== null ? parseFloat(row.max_diff) : Math.abs(currentPrice - config.targetPrice) + const worstDeviationBps = Math.round((maxDiff / config.targetPrice) * 10000) + + results.push({ + asset: config.code, + targetPrice: config.targetPrice, + targetCurrency: config.targetCurrency, + currentPrice, + currentDeviationBps, + worstDeviationBps, + sampleCount: row.sample_count ?? 1, + window, + lastUpdated: row.last_updated ?? new Date().toISOString(), + }) + } + } + + return { + network, + window, + pegs: results, + } + }) +} diff --git a/src/routes/rates.ts b/src/routes/rates.ts new file mode 100644 index 00000000..c6627ec6 --- /dev/null +++ b/src/routes/rates.ts @@ -0,0 +1,117 @@ +import type { FastifyInstance } from 'fastify' +import { activeNetwork, type NetworkName } from '../config' +import '../middleware/network' + +export interface LendingRate { + venue: string + pool: string + poolId?: string + asset: string + network: NetworkName + supplyApy: number + borrowApy: number + utilizationRate?: number + timestamp: string +} + +// Configured/known lending markets per network and asset +export const LENDING_MARKETS_REGISTRY: Record[]>> = { + testnet: { + USDC: [ + { + venue: 'blend', + pool: 'Blend USDC Pool', + poolId: 'CBUSDCBLENDTESTNET001', + supplyApy: 0.055, // 5.5% + borrowApy: 0.078, // 7.8% + utilizationRate: 0.72, + }, + { + venue: 'aquarius_lend', + pool: 'Aquarius Money Market USDC', + poolId: 'CBAQUALENDTESTNET001', + supplyApy: 0.048, + borrowApy: 0.082, + utilizationRate: 0.65, + }, + ], + XLM: [ + { + venue: 'blend', + pool: 'Blend Native XLM Pool', + poolId: 'CBXLMBLENDTESTNET001', + supplyApy: 0.032, + borrowApy: 0.051, + utilizationRate: 0.58, + }, + ], + }, + mainnet: { + USDC: [ + { + venue: 'blend', + pool: 'Blend Mainnet Prime USDC', + poolId: 'CBBLENDUSDCMAINNETPROT001', + supplyApy: 0.062, + borrowApy: 0.084, + utilizationRate: 0.81, + }, + { + venue: 'aquarius_lend', + pool: 'Aquarius Mainnet USDC Market', + poolId: 'CBAQUAMAINNETUSDC001', + supplyApy: 0.051, + borrowApy: 0.089, + utilizationRate: 0.70, + }, + ], + XLM: [ + { + venue: 'blend', + pool: 'Blend Mainnet XLM Pool', + poolId: 'CBBLENDXLMMAINNETPROT001', + supplyApy: 0.038, + borrowApy: 0.059, + utilizationRate: 0.61, + }, + ], + }, +} + +export async function registerRatesRoutes(app: FastifyInstance) { + app.get<{ + Params: { asset: string } + Querystring: { network?: string } + }>('/rates/:asset', { config: { public: true } }, async (req, reply) => { + const rawAsset = req.params.asset + const asset = rawAsset.toUpperCase() + + const requested = req.query.network ?? req.network + if (requested !== undefined && requested !== 'testnet' && requested !== 'mainnet') { + return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + } + const network: NetworkName = (requested as NetworkName) ?? activeNetwork + + const marketsForNetwork = LENDING_MARKETS_REGISTRY[network] ?? {} + const ratesData = marketsForNetwork[asset] ?? [] + + if (ratesData.length === 0) { + // Acceptance criteria: An asset with no lending market returns an empty list and a 200, not a 404 + return [] + } + + const timestamp = new Date().toISOString() + + // Sorted best-first: highest supply APY first, then lowest borrow APY + const sorted = [...ratesData] + .sort((a, b) => b.supplyApy - a.supplyApy || a.borrowApy - b.borrowApy) + .map((m) => ({ + ...m, + asset, + network, + timestamp, + })) + + return sorted + }) +} diff --git a/src/routes/volumes.ts b/src/routes/volumes.ts index 802ba03d..bef9e476 100644 --- a/src/routes/volumes.ts +++ b/src/routes/volumes.ts @@ -70,7 +70,7 @@ export async function registerVolumeRoutes(app: FastifyInstance) { AND network = $2 AND timestamp >= $3 GROUP BY source`, - [asset, network, startTime], + [asset, network, startTime] ) const byVenue: Record = {} @@ -84,6 +84,48 @@ export async function registerVolumeRoutes(app: FastifyInstance) { tradeCount += Number(row.trade_count) || 0 } + // Also query individual trade sizes to compute concentration, median, and largest trade (#302) + const tradeSizesRes = await pgPool.query( + `SELECT ( + CASE + WHEN asset_a = $1 THEN base_volume + WHEN asset_b = $1 THEN counter_volume + ELSE 0 + END + )::float AS trade_size + FROM price_points + WHERE (asset_a = $1 OR asset_b = $1) + AND network = $2 + AND timestamp >= $3 + ORDER BY trade_size ASC`, + [asset, network, startTime] + ) + + const tradeSizes: number[] = tradeSizesRes.rows + .map((r) => parseFloat(r.trade_size)) + .filter((s) => !isNaN(s) && s > 0) + + let medianSize = 0 + let largestTradeVolume = 0 + let topTradeShare = 0 + let herfindahlIndex = 0 + + if (tradeSizes.length > 0) { + const mid = Math.floor(tradeSizes.length / 2) + medianSize = tradeSizes.length % 2 === 0 + ? (tradeSizes[mid - 1] + tradeSizes[mid]) / 2 + : tradeSizes[mid] + + largestTradeVolume = tradeSizes[tradeSizes.length - 1] + + if (totalVolume > 0) { + topTradeShare = parseFloat((largestTradeVolume / totalVolume).toFixed(4)) + // Herfindahl-Hirschman Index: sum of squared market shares + const sumSq = tradeSizes.reduce((acc, s) => acc + Math.pow(s / totalVolume, 2), 0) + herfindahlIndex = parseFloat(sumSq.toFixed(4)) + } + } + return { asset, network, @@ -96,6 +138,17 @@ export async function registerVolumeRoutes(app: FastifyInstance) { byVenue, venues: Object.keys(byVenue), tradeCount, + medianSize: parseFloat(medianSize.toFixed(4)), + largestTradeVolume: parseFloat(largestTradeVolume.toFixed(4)), + topTradeShare, + concentrationScore: herfindahlIndex, + concentration: { + medianSize: parseFloat(medianSize.toFixed(4)), + largestTradeVolume: parseFloat(largestTradeVolume.toFixed(4)), + topTradeShare, + herfindahlIndex, + method: 'Herfindahl-Hirschman Index (sum of squared volume shares) and top-trade volume share', + }, } } catch (err) { return reply diff --git a/src/routes/yield.ts b/src/routes/yield.ts new file mode 100644 index 00000000..303563ee --- /dev/null +++ b/src/routes/yield.ts @@ -0,0 +1,226 @@ +import type { FastifyInstance } from 'fastify' +import { pgPool } from '../db' +import { activeNetwork, type NetworkName } from '../config' +import '../middleware/network' + +export const WINDOW_DAYS: Record = { + '24h': 1, + '7d': 7, + '30d': 30, +} + +export const IL_ASSUMPTIONS = + 'Standard constant product AMM invariant (x*y=k) with 50/50 reserve weighting and zero rebalancing' + +/** + * Compute impermanent (divergence) loss given initial price P0 and final price P1. + * Returns percentage (0 to -100). + * Formula: 2 * sqrt(k) / (1 + k) - 1, where k = P1 / P0. + */ +export function computeImpermanentLoss(p0: number, p1: number): number { + if (p0 <= 0 || p1 <= 0) return 0 + const k = p1 / p0 + const ilRatio = (2 * Math.sqrt(k)) / (1 + k) - 1 + return parseFloat((ilRatio * 100).toFixed(4)) +} + +/** + * Compute annualised Fee APR. + * Formula: (volume * (feeBp / 10000) / reserves) * (365 / windowDays) * 100. + */ +export function computeFeeApr( + volume: number, + reserves: number, + feeBp: number, + windowDays: number +): number { + if (reserves <= 0 || windowDays <= 0) return 0 + const feesEarned = volume * (feeBp / 10000) + const apr = (feesEarned / reserves) * (365 / windowDays) * 100 + return parseFloat(apr.toFixed(4)) +} + +export async function registerYieldRoutes(app: FastifyInstance) { + // GET /yield — list pools by fee APR with divergence loss & net yield (#285, #286) + app.get<{ + Querystring: { window?: string; network?: string } + }>('/yield', { config: { public: true } }, async (req, reply) => { + const window = req.query.window ?? '24h' + if (!(window in WINDOW_DAYS)) { + return reply.status(400).send({ error: 'window must be one of: 24h, 7d, 30d' }) + } + const windowDays = WINDOW_DAYS[window] + + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + try { + // Query recent pool snapshots and 24h/window volume per pool + const query = ` + WITH latest_pools AS ( + SELECT DISTINCT ON (pool_id) + pool_id, asset_a, asset_b, + (reserve_a * spot_price + reserve_b)::float AS reserves, + fee_bp, + spot_price::float AS current_price, + timestamp + FROM pool_snapshots + WHERE network = $1 + ORDER BY pool_id, timestamp DESC + ), + pool_old_prices AS ( + SELECT DISTINCT ON (pool_id) + pool_id, + spot_price::float AS old_price, + timestamp AS old_timestamp + FROM pool_snapshots + WHERE network = $1 + AND timestamp <= NOW() - ($2 || ' days')::interval + ORDER BY pool_id, timestamp DESC + ), + pool_volumes AS ( + SELECT + pool_id, + SUM(counter_volume)::float AS total_volume, + COUNT(*)::int AS trade_count, + MIN(timestamp) AS first_trade_in_window + FROM price_points + WHERE network = $1 + AND pool_id IS NOT NULL + AND timestamp >= NOW() - ($2 || ' days')::interval + GROUP BY pool_id + ) + SELECT + lp.pool_id, + lp.asset_a, + lp.asset_b, + COALESCE(lp.reserves, 0) AS reserves, + lp.fee_bp, + lp.current_price, + COALESCE(pv.total_volume, 0) AS volume, + COALESCE(pv.trade_count, 0) AS trade_count, + pop.old_price, + pop.old_timestamp + FROM latest_pools lp + LEFT JOIN pool_volumes pv ON pv.pool_id = lp.pool_id + LEFT JOIN pool_old_prices pop ON pop.pool_id = lp.pool_id + ` + + const result = await pgPool.query(query, [network, windowDays.toString()]) + + const yieldItems = result.rows.map((r) => { + const volume = parseFloat(r.volume) || 0 + const reserves = parseFloat(r.reserves) || 1 + const feeBp = r.fee_bp ?? 30 + const currentPrice = parseFloat(r.current_price) || 1 + const oldPrice = r.old_price ? parseFloat(r.old_price) : currentPrice + + const feeApr = computeFeeApr(volume, reserves, feeBp, windowDays) + const impermanentLoss = computeImpermanentLoss(oldPrice, currentPrice) + const netYield = parseFloat((feeApr + impermanentLoss).toFixed(4)) + + // Check whether the window is incomplete (e.g. pool has no history dating back to window start) + const isCompletePeriod = !!r.old_timestamp + + return { + poolId: r.pool_id, + pair: `${r.asset_a}/${r.asset_b}`, + reserves, + volume, + feeRateBp: feeBp, + feeApr, + impermanentLoss, + netYield, + isCompletePeriod, + incompleteReason: isCompletePeriod ? null : 'Incomplete historical coverage for requested window', + assumptions: IL_ASSUMPTIONS, + window, + } + }) + + // Sort by fee APR descending + yieldItems.sort((a, b) => b.feeApr - a.feeApr) + + return { + network, + window, + yields: yieldItems, + } + } catch (err) { + req.log.error(err, 'Failed to calculate pool yield') + return reply.status(500).send({ error: 'Failed to calculate pool yield' }) + } + }) + + // GET /pools/:id/il — divergence loss per pool over window (#286) + app.get<{ + Params: { id: string } + Querystring: { window?: string; network?: string } + }>('/pools/:id/il', { config: { public: true } }, async (req, reply) => { + const poolId = req.params.id + const window = req.query.window ?? '24h' + if (!(window in WINDOW_DAYS)) { + return reply.status(400).send({ error: 'window must be one of: 24h, 7d, 30d' }) + } + const windowDays = WINDOW_DAYS[window] + + const requestedNetwork = req.query.network ?? req.network + if (requestedNetwork !== undefined && requestedNetwork !== 'testnet' && requestedNetwork !== 'mainnet') { + return reply.status(400).send({ error: 'network must be one of: testnet, mainnet' }) + } + const network: NetworkName = (requestedNetwork as NetworkName) ?? activeNetwork + + const query = ` + SELECT + (SELECT spot_price::float FROM pool_snapshots + WHERE pool_id = $1 AND network = $2 + ORDER BY timestamp DESC LIMIT 1) AS current_price, + (SELECT spot_price::float FROM pool_snapshots + WHERE pool_id = $1 AND network = $2 + AND timestamp <= NOW() - ($3 || ' days')::interval + ORDER BY timestamp DESC LIMIT 1) AS old_price, + (SELECT (reserve_a * spot_price + reserve_b)::float FROM pool_snapshots + WHERE pool_id = $1 AND network = $2 + ORDER BY timestamp DESC LIMIT 1) AS reserves, + (SELECT fee_bp FROM pool_snapshots + WHERE pool_id = $1 AND network = $2 + ORDER BY timestamp DESC LIMIT 1) AS fee_bp, + (SELECT SUM(counter_volume)::float FROM price_points + WHERE pool_id = $1 AND network = $2 + AND timestamp >= NOW() - ($3 || ' days')::interval) AS volume + ` + + const res = await pgPool.query(query, [poolId, network, windowDays.toString()]) + const row = res.rows[0] + + if (!row || row.current_price === null) { + return reply.status(404).send({ error: `Pool ${poolId} not found on ${network}` }) + } + + const currentPrice = parseFloat(row.current_price) + const oldPrice = row.old_price ? parseFloat(row.old_price) : currentPrice + const reserves = parseFloat(row.reserves) || 1 + const volume = parseFloat(row.volume) || 0 + const feeBp = row.fee_bp ?? 30 + + const feeApr = computeFeeApr(volume, reserves, feeBp, windowDays) + const impermanentLoss = computeImpermanentLoss(oldPrice, currentPrice) + const netYield = parseFloat((feeApr + impermanentLoss).toFixed(4)) + + return { + poolId, + network, + window, + currentPrice, + startPrice: oldPrice, + priceRatio: parseFloat((currentPrice / oldPrice).toFixed(4)), + feeApr, + impermanentLoss, + netYield, + assumptions: IL_ASSUMPTIONS, + } + }) +}