diff --git a/.changeset/numeric-price-precision.md b/.changeset/numeric-price-precision.md new file mode 100644 index 00000000..b9472a55 --- /dev/null +++ b/.changeset/numeric-price-precision.md @@ -0,0 +1,10 @@ +--- +'lens': patch +--- + +Fix numeric price and volume precision across aggregations (#230). + +- Remove `::float` casts inside intermediate aggregate expressions in `/candles`, `/prices/history`, and GraphQL `getPriceHistory`. +- Remove `::float` casts from `/price/:assetA/:assetB/history` and query raw numerics directly from `price_aggregates`. +- Add explicit, documented edge serialization formatters in `src/pricing/precision.ts`: prices up to 18 decimal places matching `Decimal(36, 18)`, and volumes up to 7 decimal places matching Stellar stroops (`Decimal(36, 7)`). +- Prevent multi-venue floating-point accumulation drift in `/volumes/:asset`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2929c8a9..5ebd9d68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - run: npm ci || npm install - run: node node_modules/prisma/build/index.js generate @@ -59,7 +59,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - run: npm ci || npm install - run: node node_modules/prisma/build/index.js generate diff --git a/.github/workflows/dropped-tests.yml b/.github/workflows/dropped-tests.yml index 3c8f48b6..329bbd38 100644 --- a/.github/workflows/dropped-tests.yml +++ b/.github/workflows/dropped-tests.yml @@ -38,7 +38,7 @@ jobs: - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: '20' + node-version: '22' - name: Compare test cases against the base run: | diff --git a/.github/workflows/live-spec-validation.yml b/.github/workflows/live-spec-validation.yml index d15692ba..e82561ee 100644 --- a/.github/workflows/live-spec-validation.yml +++ b/.github/workflows/live-spec-validation.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - name: Install dependencies diff --git a/.github/workflows/load.yml b/.github/workflows/load.yml index 8c14e629..ef3164d3 100644 --- a/.github/workflows/load.yml +++ b/.github/workflows/load.yml @@ -69,7 +69,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - name: Install dependencies diff --git a/.github/workflows/openapi-publish.yml b/.github/workflows/openapi-publish.yml index c9b0f3e5..56d5cd7e 100644 --- a/.github/workflows/openapi-publish.yml +++ b/.github/workflows/openapi-publish.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - run: npm ci - run: npm run openapi:gen diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5ce0174..b0c02bdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: npm - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index 4f6b66d3..0467d7e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build -FROM node:20-slim AS builder +FROM node:22-slim AS builder WORKDIR /app @@ -13,7 +13,7 @@ COPY . . RUN npm run build # Stage 2: Run -FROM node:20-slim +FROM node:22-slim WORKDIR /app diff --git a/README.md b/README.md index 24302db6..15f0e2bb 100644 --- a/README.md +++ b/README.md @@ -888,6 +888,15 @@ curl -s http://localhost:3002/price/XLM/USDC | jq '{price, ammPrice, lastUpdated *(Note: timestamp values above reflect the dynamic hourly anchor at seed execution time and request time).* +## Numeric Precision & Serialization + +Lens enforces strict arbitrary-precision numeric aggregation across SQL queries to eliminate floating-point representation artifacts and order-dependent rounding errors: + +- **Database Precision**: Declared in `prisma/schema.prisma` as `Decimal(36, 18)` for prices and `Decimal(36, 7)` for volumes (matching Stellar 7-decimal stroop precision). +- **Exact Aggregations**: All intermediate SQL aggregations (`SUM`, `array_agg`, `MAX`, `MIN`) are computed in PostgreSQL using exact `numeric` arithmetic without `::float` casts inside aggregate expressions. +- **Edge Serialization**: Values are cast once at the serialization boundary to documented precision: + - Prices: up to 18 decimal places (`PRICE_DECIMAL_PLACES = 18`). + - Volumes: up to 7 decimal places (`VOLUME_DECIMAL_PLACES = 7`). ## Rate Limiting & 429 Handling diff --git a/clients/typescript/src/schema.d.ts b/clients/typescript/src/schema.d.ts index 6b07abba..18003a59 100644 --- a/clients/typescript/src/schema.d.ts +++ b/clients/typescript/src/schema.d.ts @@ -180,7 +180,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get price history buckets */ + /** + * Get price history buckets + * @description Returns price aggregate buckets (1m, 5m, 1h, 24h). Prices are serialized up to 18 decimal places and volumes up to 7 decimal places matching Stellar stroop precision. + */ get: operations["getPriceHistory"]; put?: never; post?: never; @@ -214,7 +217,10 @@ export interface paths { path?: never; cookie?: never; }; - /** Get OHLCV candle data */ + /** + * Get OHLCV candle data + * @description Aggregates price_points into interval-sized buckets using exact numeric arithmetic. Prices are serialized up to 18 decimal places and volumes up to 7 decimal places matching Stellar stroop precision. + */ get: operations["getCandles"]; put?: never; post?: never; @@ -233,7 +239,7 @@ export interface paths { }; /** * Get historical 1-minute price snapshots - * @description Returns minute-resolution price snapshots for a pair over [from, to], optionally aggregated into 5m or 1h buckets. Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix). + * @description Returns minute-resolution price snapshots for a pair over [from, to], optionally aggregated into 5m or 1h buckets. Aggregations use exact numeric arithmetic; prices are serialized up to 18 decimal places and volumes up to 7 decimal places. Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix). */ get: operations["getPriceSnapshots"]; put?: never; @@ -705,10 +711,60 @@ export interface paths { patch?: never; trace?: never; }; + "/rates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Query latest lending and borrow rates across pools + * @description Returns the latest supply and borrow rates, utilisation, and reserves for lending markets. + */ + get: operations["getLendingRates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { + LendingRateEntry: { + id: string; + /** @example blend */ + venue: string; + /** @example blend */ + source: string; + /** @example mainnet */ + network: string; + poolId: string; + poolName?: string | null; + /** @example USDC */ + asset: string; + assetAddress?: string | null; + supplyApy: number; + borrowApy: number; + supplyApr?: number | null; + borrowApr?: number | null; + /** @description Borrowed / supplied as a fraction. Null for venues that publish rates only. */ + utilization?: number | null; + totalSupply?: number | null; + totalBorrowed?: number | null; + reserves?: number | null; + ledger?: number; + /** Format: date-time */ + timestamp: string; + }; + LendingRatesResponse: { + network: string; + rates: components["schemas"]["LendingRateEntry"][]; + }; InternalErrorResponse: { /** * @example internal_error @@ -3118,4 +3174,41 @@ export interface operations { 402: components["responses"]["PaymentRequired"]; }; }; + getLendingRates: { + parameters: { + query?: { + /** @description Filter by asset symbol (e.g. USDC, XLM) */ + asset?: string; + /** @description Filter by pool contract ID */ + poolId?: string; + /** @description Stellar network to query (testnet or mainnet) */ + network?: "testnet" | "mainnet"; + /** @description Maximum number of rate records to return */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Latest lending rates */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LendingRatesResponse"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 429: components["responses"]["TooManyRequests"]; + }; + }; } diff --git a/openapi.json b/openapi.json index 0c401f5e..c76f5733 100644 --- a/openapi.json +++ b/openapi.json @@ -640,6 +640,7 @@ "get": { "operationId": "getPriceHistory", "summary": "Get price history buckets", + "description": "Returns price aggregate buckets (1m, 5m, 1h, 24h). Prices are serialized up to 18 decimal places and volumes up to 7 decimal places matching Stellar stroop precision.", "security": [], "parameters": [ { @@ -838,6 +839,7 @@ "get": { "operationId": "getCandles", "summary": "Get OHLCV candle data", + "description": "Aggregates price_points into interval-sized buckets using exact numeric arithmetic. Prices are serialized up to 18 decimal places and volumes up to 7 decimal places matching Stellar stroop precision.", "security": [ { "x402": [] @@ -971,7 +973,7 @@ "get": { "operationId": "getPriceSnapshots", "summary": "Get historical 1-minute price snapshots", - "description": "Returns minute-resolution price snapshots for a pair over [from, to], optionally aggregated into 5m or 1h buckets. Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix).", + "description": "Returns minute-resolution price snapshots for a pair over [from, to], optionally aggregated into 5m or 1h buckets. Aggregations use exact numeric arithmetic; prices are serialized up to 18 decimal places and volumes up to 7 decimal places. Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix).", "security": [ { "x402": [] @@ -2845,7 +2847,7 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "description": "Invalid parameters" }, "429": { "$ref": "#/components/responses/TooManyRequests" diff --git a/openapi.yaml b/openapi.yaml index 72d5778b..7b711e0c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -460,6 +460,9 @@ paths: get: operationId: getPriceHistory summary: Get price history buckets + description: >- + Returns price aggregate buckets (1m, 5m, 1h, 24h). Prices are serialized up to 18 + decimal places and volumes up to 7 decimal places matching Stellar stroop precision. security: [] parameters: - name: assetA @@ -572,6 +575,9 @@ paths: get: operationId: getCandles summary: Get OHLCV candle data + description: >- + Aggregates price_points into interval-sized buckets using exact numeric arithmetic. + Prices are serialized up to 18 decimal places and volumes up to 7 decimal places matching Stellar stroop precision. security: - x402: [] parameters: @@ -668,8 +674,9 @@ paths: summary: Get historical 1-minute price snapshots description: >- Returns minute-resolution price snapshots for a pair over [from, to], - optionally aggregated into 5m or 1h buckets. Snapshots are retained for - ~30 days. Gated by x402 (matches the /price prefix). + optionally aggregated into 5m or 1h buckets. Aggregations use exact numeric arithmetic; + prices are serialized up to 18 decimal places and volumes up to 7 decimal places. + Snapshots are retained for ~30 days. Gated by x402 (matches the /price prefix). security: - x402: [] parameters: @@ -1921,7 +1928,7 @@ paths: schema: $ref: '#/components/schemas/LendingRatesResponse' '400': - $ref: '#/components/responses/BadRequest' + description: Invalid parameters '429': $ref: '#/components/responses/TooManyRequests' diff --git a/package-lock.json b/package-lock.json index cfe90959..c3aa959c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1256,114 +1256,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@redocly/ajv": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", - "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js-replace": "^1.0.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@redocly/config": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", - "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core": { - "version": "1.34.21", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.21.tgz", - "integrity": "sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@redocly/ajv": "8.11.2", - "@redocly/config": "0.22.0", - "colorette": "1.4.0", - "https-proxy-agent": "7.0.6", - "js-levenshtein": "1.1.6", - "js-yaml": "4.3.2", - "minimatch": "5.1.9", - "pluralize": "8.0.0", - "yaml-ast-parser": "0.0.43" - }, - "engines": { - "node": ">=18.17.0", - "npm": ">=9.5.0" - } - }, - "node_modules/@redocly/openapi-core/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@redocly/openapi-core/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@redocly/openapi-core/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.16", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", - "integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", "license": "MIT", "engines": { "node": ">=8" @@ -1701,6 +1597,106 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.21", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.21.tgz", + "integrity": "sha512-yXMiBFYJbP5JOaxPzx2tJhK+v8h2Wu2uhad1Ro6GzVT8E3F4GcQ7sbLPJ9w4zvGjfxroD0bIh5B0pDtnLV/qUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.16", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz", diff --git a/src/__tests__/numericPrecision.test.ts b/src/__tests__/numericPrecision.test.ts new file mode 100644 index 00000000..1d75b195 --- /dev/null +++ b/src/__tests__/numericPrecision.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Fastify from 'fastify' + +const { mockQuery } = vi.hoisted(() => ({ mockQuery: vi.fn() })) + +vi.mock('../db', () => ({ + prisma: {}, + pgPool: { query: mockQuery }, +})) + +vi.mock('../redis', () => ({ + getCachedPrice: vi.fn(), + setCachedPrice: vi.fn(), +})) + +vi.mock('../aggregator/bestRoute', () => ({ + getBestRoute: vi.fn(), +})) + +import { registerCandleRoutes } from '../routes/candles' +import { registerVolumeRoutes } from '../routes/volumes' +import { registerHistoryRoutes } from '../api/history' +import { registerRESTRoutes } from '../api/rest' +import { registerNetworkSelector } from '../middleware/network' + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(registerNetworkSelector) + await registerCandleRoutes(app) + await registerVolumeRoutes(app) + await registerHistoryRoutes(app) + await registerRESTRoutes(app) + await app.ready() + return app +} + +describe('Issue #230: numeric precision across aggregations and edge serialization', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('GET /candles/:assetA/:assetB', () => { + it('aggregates without ::float inside aggregate expressions and serializes exact volume', async () => { + let capturedSql = '' + mockQuery.mockImplementation(async (sql: string) => { + capturedSql = sql + // In PostgreSQL, SUM(numeric) of 0.1 and 0.2 produces exact decimal '0.3000000', + // whereas SUM(float) produces 0.30000000000000004. + return { + rows: [ + { + time: new Date('2025-01-01T00:00:00Z'), + open: '0.000000100000000001', + high: '0.000000100000000002', + low: '0.000000100000000001', + close: '0.000000100000000002', + volume: '0.3000000', + }, + ], + } + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/candles/XLM/USDC?interval=1h' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + + // 1. Acceptance criteria: No ::float cast remains inside an aggregate expression + expect(capturedSql).not.toMatch(/(SUM|MAX|MIN|array_agg)\([^)]*::float/i) + + // 2. Acceptance criteria: Output precision is exact rather than float accumulation noise + const candle = body.candles[0] + expect(candle.volume).toBe(0.3) + expect(candle.open).toBe(1.00000000001e-7) + expect(candle.close).toBe(1.00000000002e-7) + await app.close() + }) + }) + + describe('GET /volumes/:asset', () => { + it('sums multi-venue volume without accumulating floating-point representation error', async () => { + let capturedSql = '' + mockQuery.mockImplementation(async (sql: string) => { + capturedSql = sql + return { + rows: [ + { + total_volume: '0.3000000', + by_venue: { SDEX: 0.1, AMM: 0.2 }, + venues: ['AMM', 'SDEX'], + trade_count: 2, + observed_sample_count: 2, + trade_count_by_venue: { SDEX: 1, AMM: 1 }, + median_trade_size: '0.15', + top_trade_volume_share: '0.6666666666666666', + volume_concentration: '0.5555555555555556', + latest_trade_at: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + } + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/volumes/XLM?window=24h' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + + // Acceptance criteria: No ::float cast inside aggregate expression + expect(capturedSql).not.toMatch(/(SUM|MAX|MIN|array_agg)\([^)]*::float/i) + + // In float arithmetic: 0.1 + 0.2 = 0.30000000000000004 + // In documented 7-decimal volume precision: totalVolume = 0.3 exactly + expect(body.byVenue.SDEX).toBe(0.1) + expect(body.byVenue.AMM).toBe(0.2) + expect(body.totalVolume).toBe(0.3) + await app.close() + }) + }) + + describe('GET /prices/history', () => { + it('queries price_snapshots without ::float inside aggregates and preserves exact volume', async () => { + let capturedSql = '' + mockQuery.mockImplementation(async (sql: string) => { + capturedSql = sql + return { + rows: [ + { + bucket: new Date('2025-01-01T00:00:00Z'), + price: '0.000000100000000001', + volume: '0.3000000', + }, + ], + } + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/prices/history?pair=XLM/USDC&interval=1m' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + + // Acceptance criteria: No ::float cast remains inside an aggregate expression + expect(capturedSql).not.toMatch(/(SUM|MAX|MIN|array_agg)\([^)]*::float/i) + + expect(body.points[0].volume).toBe(0.3) + expect(body.points[0].price).toBe(1.00000000001e-7) + await app.close() + }) + }) + + describe('GET /price/:assetA/:assetB/history', () => { + it('reads price_aggregates without ::float casts and formats values at the edge', async () => { + let capturedSql = '' + mockQuery.mockImplementation(async (sql: string) => { + capturedSql = sql + return { + rows: [ + { + bucket: new Date('2025-01-01T00:00:00Z'), + window: '1h', + vwap: '0.000000100000000001', + sdex_vwap: '0.000000100000000001', + amm_vwap: null, + volume: '0.3000000', + trade_count: 5, + open_price: '0.000000100000000001', + close_price: '0.000000100000000002', + high_price: '0.000000100000000002', + low_price: '0.000000100000000001', + }, + ], + } + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/price/XLM/USDC/history?window=1h' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + + // Acceptance criteria: No ::float cast in the query + expect(capturedSql).not.toMatch(/::float/) + + const bucket = body.buckets[0] + expect(bucket.volume).toBe(0.3) + expect(bucket.vwap).toBe(1.00000000001e-7) + expect(bucket.open).toBe(1.00000000001e-7) + expect(bucket.close).toBe(1.00000000002e-7) + await app.close() + }) + }) +}) diff --git a/src/api/graphql.ts b/src/api/graphql.ts index d258b158..3dcd4e81 100644 --- a/src/api/graphql.ts +++ b/src/api/graphql.ts @@ -26,6 +26,7 @@ import { import { priceEmitter, PRICE_PUBLISHED, type PricePublishedEvent } from '../events' import { HISTORY_INTERVAL_SECONDS, MAX_HISTORY_POINTS } from './history' import { getMaxGraphQLQueryDepth, queryDepthValidationRules } from './queryDepth' +import { formatPrice, formatVolume } from '../pricing/precision' const WINDOW_SECONDS: Record = { ...HISTORY_INTERVAL_SECONDS, @@ -444,9 +445,9 @@ const resolvers = { const limitParam = `$${params.length}` const result = await pgPool.query( - `SELECT bucket, window, vwap::float, sdex_vwap::float, amm_vwap::float, - volume::float, trade_count, open_price::float, close_price::float, - high_price::float, low_price::float + `SELECT bucket, window, vwap, sdex_vwap, amm_vwap, + volume, trade_count, open_price, close_price, + high_price, low_price FROM price_aggregates WHERE ${conditions.join(' AND ')} ORDER BY bucket DESC @@ -456,15 +457,15 @@ const resolvers = { return result.rows.map(r => ({ bucket: (r.bucket instanceof Date ? r.bucket : new Date(r.bucket)).toISOString(), window: r.window, - vwap: r.vwap, - sdexVwap: r.sdex_vwap, - ammVwap: r.amm_vwap, - volume: r.volume, - tradeCount: r.trade_count, - open: r.open_price, - close: r.close_price, - high: r.high_price, - low: r.low_price, + vwap: formatPrice(r.vwap), + sdexVwap: formatPrice(r.sdex_vwap), + ammVwap: formatPrice(r.amm_vwap), + volume: r.volume !== null && r.volume !== undefined ? formatVolume(r.volume) : null, + tradeCount: r.trade_count !== null && r.trade_count !== undefined ? Number(r.trade_count) : null, + open: formatPrice(r.open_price), + close: formatPrice(r.close_price), + high: formatPrice(r.high_price), + low: formatPrice(r.low_price), })) }, diff --git a/src/api/history.ts b/src/api/history.ts index 70d8f61e..0756006e 100644 --- a/src/api/history.ts +++ b/src/api/history.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type +import { formatPrice, formatVolume } from '../pricing/precision' /** Supported aggregation intervals → bucket width in seconds. */ export const HISTORY_INTERVAL_SECONDS: Record = { @@ -86,8 +87,8 @@ export async function queryHistory( const result = await pgPool.query( `SELECT to_timestamp(floor(EXTRACT(EPOCH FROM ts) / $1) * $1) AS bucket, - (array_agg(price::float ORDER BY ts DESC))[1] AS price, - SUM(volume::float) AS volume + (array_agg(price ORDER BY ts DESC))[1] AS price, + SUM(volume) AS volume FROM price_snapshots WHERE pair = $2 AND network = $5 AND ts >= $3 @@ -99,8 +100,8 @@ export async function queryHistory( return result.rows.map(r => ({ ts: new Date(r.bucket).toISOString(), - price: Number(r.price), - volume: Number(r.volume), + price: formatPrice(r.price) ?? 0, + volume: formatVolume(r.volume), })) } diff --git a/src/pricing/precision.ts b/src/pricing/precision.ts new file mode 100644 index 00000000..6625e56f --- /dev/null +++ b/src/pricing/precision.ts @@ -0,0 +1,44 @@ +/** + * Serialization precision standards for Lens prices and volumes. + * + * Background (Issue #230): + * PostgreSQL schema (`prisma/schema.prisma`) declares: + * - Prices: `Decimal(36, 18)` (18 decimal places of fractional precision) + * - Volumes: `Decimal(36, 7)` (7 decimal places of fractional precision, matching Stellar stroops) + * + * Intermediate aggregations (SUM, AVG, MIN, MAX, array_agg) are computed in PostgreSQL + * using exact arbitrary-precision `numeric` arithmetic rather than IEEE-754 `float8`. + * This prevents order-dependent floating-point accumulation errors and precision loss + * on small fractions (such as 1e-7 prices or multi-venue volume totals). + * + * At the serialization edge, values are cast once to JavaScript numbers with explicit, + * documented precision: + * - Price precision: up to 18 decimal places (`PRICE_DECIMAL_PLACES = 18`). + * - Volume precision: up to 7 decimal places (`VOLUME_DECIMAL_PLACES = 7`). + */ + +export const PRICE_DECIMAL_PLACES = 18 +export const VOLUME_DECIMAL_PLACES = 7 + +/** + * Format and cast a price value at the serialization edge with explicit precision. + * Preserves exact decimal digits (up to 18 decimal places) while stripping floating-point + * representation artifacts. Returns `null` if the input is `null` or `undefined`. + */ +export function formatPrice(value: string | number | null | undefined): number | null { + if (value === null || value === undefined) return null + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) return null + return Number(num.toFixed(PRICE_DECIMAL_PLACES)) +} + +/** + * Format and cast a volume value at the serialization edge with explicit precision. + * Denominated in Stellar stroops (7 decimal places). Returns `0` if null or undefined. + */ +export function formatVolume(value: string | number | null | undefined): number { + if (value === null || value === undefined) return 0 + const num = typeof value === 'number' ? value : Number(value) + if (!Number.isFinite(num)) return 0 + return Number(num.toFixed(VOLUME_DECIMAL_PLACES)) +} diff --git a/src/routes/candles.ts b/src/routes/candles.ts index 0ef20b6f..39cd5138 100644 --- a/src/routes/candles.ts +++ b/src/routes/candles.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' import '../middleware/network' // declares req.network on the FastifyRequest type +import { formatPrice, formatVolume } from '../pricing/precision' const INTERVAL_SECONDS: Record = { '1m': 60, @@ -60,11 +61,11 @@ export async function registerCandleRoutes(app: FastifyInstance) { const result = await pgPool.query( `SELECT to_timestamp(floor(EXTRACT(EPOCH FROM timestamp) / $1) * $1) AS time, - (array_agg(price::float ORDER BY timestamp ASC))[1] AS open, - MAX(price::float) AS high, - MIN(price::float) AS low, - (array_agg(price::float ORDER BY timestamp DESC))[1] AS close, - SUM(base_volume::float) AS volume + (array_agg(price ORDER BY timestamp ASC, id ASC))[1] AS open, + MAX(price) AS high, + MIN(price) AS low, + (array_agg(price ORDER BY timestamp DESC, id DESC))[1] AS close, + SUM(base_volume) AS volume FROM price_points WHERE pair_key = $2 AND network = $5 @@ -83,11 +84,11 @@ export async function registerCandleRoutes(app: FastifyInstance) { to: to.toISOString(), candles: result.rows.map(r => ({ time: r.time, - open: Number(r.open), - high: Number(r.high), - low: Number(r.low), - close: Number(r.close), - volume: Number(r.volume), + open: formatPrice(r.open), + high: formatPrice(r.high), + low: formatPrice(r.low), + close: formatPrice(r.close), + volume: formatVolume(r.volume), })), } })