Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graphql-exact-decimal-prices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'lens': minor
---

Expose exact decimal strings (`priceExact`, `volume24hExact`, `vwap1hExact`, and the other `*Exact` fields) on the GraphQL `AggregatedPrice` and `PriceBucket` types, so PostgreSQL numeric precision survives the GraphQL response. The matching `Float` fields are deprecated and unchanged.
32 changes: 32 additions & 0 deletions docs/GRAPHQL_DECIMAL_MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# GraphQL decimal field migration

GraphQL `Float` uses IEEE 754 binary floating point and cannot preserve every
value stored in PostgreSQL `numeric`. The API now exposes parallel `Decimal`
fields serialized as decimal strings. These fields preserve the PostgreSQL
numeric text through the GraphQL response; clients should keep them as strings
or parse them with an arbitrary-precision decimal library rather than convert
them to JavaScript `Number`.

For example, request `priceExact` beside the deprecated `price` field:

```graphql
query {
getPrice(assetA: "XLM", assetB: "USDC") {
priceExact
volume24hExact
}
}
```

`AggregatedPrice` exposes exact companions for price, per-source prices,
volumes, VWAPs, and 24-hour price change. `PriceBucket` exposes exact
companions for VWAP, volume, and OHLC values. Existing `Float` fields remain
available and return their historical numeric shape, but are marked deprecated
in the schema so clients can migrate without a flag day. They will only be
removed in a future breaking API release; no removal date is set.

The trade-off is that exact values arrive as strings, which requires explicit
decimal handling in clients. This avoids returning a plausible rounded number
when stored precision matters. Price and volume SQL now retains PostgreSQL
`numeric` calculations; conversion to `Number` happens only for the deprecated
compatibility fields and the existing REST response shapes.
59 changes: 40 additions & 19 deletions schema.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
scalar Decimal

type Asset {
code: String!
issuer: String
Expand All @@ -16,18 +18,29 @@ type AggregatedPrice {
assetA: String!
assetB: String!
pairKey: String!
price: Float!
sdexPrice: Float!
ammPrice: Float!
price: Float! @deprecated(reason: "Use priceExact to preserve decimal precision")
priceExact: Decimal!
sdexPrice: Float! @deprecated(reason: "Use sdexPriceExact to preserve decimal precision")
sdexPriceExact: Decimal!
ammPrice: Float! @deprecated(reason: "Use ammPriceExact to preserve decimal precision")
ammPriceExact: Decimal!
bestRoute: String!
volume24h: Float!
sdexVolume24h: Float!
ammVolume24h: Float!
vwap1m: Float!
vwap5m: Float!
vwap1h: Float!
vwap24h: Float!
priceChange24h: Float!
volume24h: Float! @deprecated(reason: "Use volume24hExact to preserve decimal precision")
volume24hExact: Decimal!
sdexVolume24h: Float! @deprecated(reason: "Use sdexVolume24hExact to preserve decimal precision")
sdexVolume24hExact: Decimal!
ammVolume24h: Float! @deprecated(reason: "Use ammVolume24hExact to preserve decimal precision")
ammVolume24hExact: Decimal!
vwap1m: Float! @deprecated(reason: "Use vwap1mExact to preserve decimal precision")
vwap1mExact: Decimal!
vwap5m: Float! @deprecated(reason: "Use vwap5mExact to preserve decimal precision")
vwap5mExact: Decimal!
vwap1h: Float! @deprecated(reason: "Use vwap1hExact to preserve decimal precision")
vwap1hExact: Decimal!
vwap24h: Float! @deprecated(reason: "Use vwap24hExact to preserve decimal precision")
vwap24hExact: Decimal!
priceChange24h: Float! @deprecated(reason: "Use priceChange24hExact to preserve decimal precision")
priceChange24hExact: Decimal!
lastUpdated: String!
sources: Int!
confidence: String!
Expand All @@ -47,15 +60,23 @@ type RouteInfo {
type PriceBucket {
bucket: String!
window: String!
vwap: Float!
sdexVwap: Float
ammVwap: Float
volume: Float!
vwap: Float! @deprecated(reason: "Use vwapExact to preserve decimal precision")
vwapExact: Decimal!
sdexVwap: Float @deprecated(reason: "Use sdexVwapExact to preserve decimal precision")
sdexVwapExact: Decimal
ammVwap: Float @deprecated(reason: "Use ammVwapExact to preserve decimal precision")
ammVwapExact: Decimal
volume: Float! @deprecated(reason: "Use volumeExact to preserve decimal precision")
volumeExact: Decimal!
tradeCount: Int!
open: Float
close: Float
high: Float
low: Float
open: Float @deprecated(reason: "Use openExact to preserve decimal precision")
openExact: Decimal
close: Float @deprecated(reason: "Use closeExact to preserve decimal precision")
closeExact: Decimal
high: Float @deprecated(reason: "Use highExact to preserve decimal precision")
highExact: Decimal
low: Float @deprecated(reason: "Use lowExact to preserve decimal precision")
lowExact: Decimal
}

type PriceUpdate {
Expand Down
20 changes: 10 additions & 10 deletions src/__tests__/aggregateRefreshNetwork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
addedJobs, queueNames, workers,
mockUpsert, mockSetCachedPrice,
mockCalculateVWAP, mockCalculateOHLCV, mockGetAggregatedPrice, mockGetBestRoute,
mockCalculateVWAPExact, mockCalculateOHLCVExact, mockGetAggregatedPrice, mockGetBestRoute,
} = vi.hoisted(() => ({
addedJobs: [] as Array<{ queue: string; name: string; data: any; opts: any }>,
queueNames: [] as string[],
workers: [] as Array<{ name: string; processor: (job: any) => Promise<void> }>,
mockUpsert: vi.fn(),
mockSetCachedPrice: vi.fn(),
mockCalculateVWAP: vi.fn(),
mockCalculateOHLCV: vi.fn(),
mockCalculateVWAPExact: vi.fn(),
mockCalculateOHLCVExact: vi.fn(),
mockGetAggregatedPrice: vi.fn(),
mockGetBestRoute: vi.fn(),
}))
Expand Down Expand Up @@ -51,8 +51,8 @@ vi.mock('../db', () => ({
vi.mock('../redis', () => ({ setCachedPrice: mockSetCachedPrice }))

vi.mock('../aggregator/vwap', () => ({
calculateVWAP: mockCalculateVWAP,
calculateOHLCV: mockCalculateOHLCV,
calculateVWAPExact: mockCalculateVWAPExact,
calculateOHLCVExact: mockCalculateOHLCVExact,
getAggregatedPrice: mockGetAggregatedPrice,
}))

Expand Down Expand Up @@ -89,8 +89,8 @@ describe('aggregateRefresh runs once per enabled network', () => {
queueNames.length = 0
workers.length = 0
vi.clearAllMocks()
mockCalculateVWAP.mockResolvedValue(0.25)
mockCalculateOHLCV.mockResolvedValue({ open: 0.2, high: 0.3, low: 0.2, close: 0.25, volume: 100, tradeCount: 3 })
mockCalculateVWAPExact.mockResolvedValue('0.25')
mockCalculateOHLCVExact.mockResolvedValue({ open: '0.2', high: '0.3', low: '0.2', close: '0.25', volume: '100', tradeCount: 3 })
mockGetAggregatedPrice.mockResolvedValue({ price: 0.25 })
mockGetBestRoute.mockResolvedValue({ route: 'SDEX' })
mockSetCachedPrice.mockResolvedValue(undefined)
Expand Down Expand Up @@ -133,14 +133,14 @@ describe('aggregateRefresh runs once per enabled network', () => {

await processor({ data: { pairKey: mainnetPair.pairKey, pair: mainnetPair, network: 'mainnet' } })

expect(mockGetAggregatedPrice).toHaveBeenCalledWith(mainnetPair.pairKey, 'mainnet')
expect(mockGetAggregatedPrice).toHaveBeenCalledWith(mainnetPair.pairKey, 'mainnet', { decimalFormat: 'string' })
expect(mockGetBestRoute).toHaveBeenCalledWith(
mainnetPair.assetA, mainnetPair.assetB, mainnetPair.pairKey, 1000, 'mainnet'
)
for (const call of mockCalculateVWAP.mock.calls) {
for (const call of mockCalculateVWAPExact.mock.calls) {
expect(call).toContain('mainnet')
}
for (const call of mockCalculateOHLCV.mock.calls) {
for (const call of mockCalculateOHLCVExact.mock.calls) {
expect(call).toContain('mainnet')
}
expect(mockSetCachedPrice).toHaveBeenCalledWith(
Expand Down
6 changes: 5 additions & 1 deletion src/__tests__/graphqlQueryDepth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ describe('GraphQL query depth limit', () => {
beforeEach(async () => {
process.env.GRAPHQL_MAX_QUERY_DEPTH = '2'
vi.clearAllMocks()
vi.mocked(getCachedPrice).mockResolvedValue(JSON.stringify({ price: 0.1234 }))
vi.mocked(getCachedPrice).mockResolvedValue(JSON.stringify({
price: 0.1234, priceExact: '0.1234', sdexPriceExact: '0.1234', ammPriceExact: '0',
volume24hExact: '0', sdexVolume24hExact: '0', ammVolume24hExact: '0', vwap1mExact: '0',
vwap5mExact: '0', vwap1hExact: '0', vwap24hExact: '0', priceChange24hExact: '0',
}))
app = await buildServer()
})

Expand Down
37 changes: 36 additions & 1 deletion src/__tests__/graphqlSubscription.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ vi.mock('../redis', () => ({ getCachedPrice: vi.fn() }))
vi.mock('../db', () => ({ pgPool: { query: vi.fn() } }))
vi.mock('../aggregator/vwap', () => ({ getAggregatedPrice: vi.fn() }))
vi.mock('../aggregator/bestRoute', () => ({ getBestRoute: vi.fn() }))
vi.mock('../config', () => ({ config: { pairs: [] } }))
vi.mock('../config', () => ({
config: { pairs: [{ pairKey: 'USDC/XLM', assetA: { code: 'XLM', issuer: null }, assetB: { code: 'USDC', issuer: 'ISSUER' } }] },
activeNetwork: 'testnet',
}))

import { registerGraphQL } from '../api/graphql'
import { getAggregatedPrice } from '../aggregator/vwap'
import { getBestRoute } from '../aggregator/bestRoute'
import { publishPriceUpdate, priceEmitter } from '../events'

const SUBPROTOCOL = 'graphql-transport-ws'
Expand Down Expand Up @@ -181,3 +186,33 @@ describe('GraphQL priceUpdated subscription', () => {
ws.close()
})
})

describe('GraphQL exact decimal fields', () => {
it('returns stored decimal text without converting it through a double', async () => {
vi.mocked(getAggregatedPrice).mockResolvedValue({
price: '0.123456789012345678', sdexPrice: '0.123456789012345678', ammPrice: '0',
volume24h: '0', sdexVolume24h: '0', ammVolume24h: '0',
vwap1m: '0', vwap5m: '0', vwap1h: '0', vwap24h: '0',
priceChange24h: '0', sources: 0,
confidence: 'unknown', lastTradeAgeSeconds: null, stale: false,
} as any)
vi.mocked(getBestRoute).mockResolvedValue({ route: 'SDEX' } as any)

const { app } = await buildServer()
try {
const response = await app.inject({
method: 'POST',
url: '/graphql',
payload: { query: '{ getPrice(assetA: "XLM", assetB: "USDC") { priceExact sdexPriceExact } }' },
})
expect(response.statusCode).toBe(200)
expect(response.json().data.getPrice).toEqual({
priceExact: '0.123456789012345678',
sdexPriceExact: '0.123456789012345678',
})
expect(String(Number('0.123456789012345678'))).not.toBe('0.123456789012345678')
} finally {
await app.close()
}
})
})
2 changes: 1 addition & 1 deletion src/__tests__/pricingFixture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function installFixtureQuery() {
const sorted = [...rows].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
const prices = sorted.map(row => row.price)
return { rows: [{
open: prices[0], high: Math.max(...prices), low: Math.min(...prices), close: prices[prices.length - 1],
open: String(prices[0]), high: String(Math.max(...prices)), low: String(Math.min(...prices)), close: String(prices[prices.length - 1]),
volume: String(sorted.reduce((sum, row) => sum + row.baseVolume, 0)), trade_count: String(sorted.length),
}] }
}
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/vwapNetwork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ vi.mock('../db', () => ({
pgPool: { query: mockQuery },
}))

import { calculateVWAP, calculateOHLCV, getPriceChange24h, getAggregatedPrice } from '../aggregator/vwap'
import { calculateVWAP, calculateVWAPExact, calculateOHLCV, getPriceChange24h, getAggregatedPrice } from '../aggregator/vwap'
import type { NetworkName } from '../config'

// ── A seeded stand-in for the two `price_points`/`pool_snapshots` tables ──────
Expand Down Expand Up @@ -181,6 +181,11 @@ describe('aggregator network scoping', () => {
installSeededDb()
})

it('keeps PostgreSQL decimal text intact for the exact VWAP path', async () => {
mockQuery.mockResolvedValueOnce({ rows: [{ vwap: '0.123456789012345678' }] })
await expect(calculateVWAPExact(PAIR, 60, 'testnet')).resolves.toBe('0.123456789012345678')
})

it('calculateVWAP returns a different price per network for the same pairKey', async () => {
const testnet = await calculateVWAP(PAIR, 60, 'testnet')
const mainnet = await calculateVWAP(PAIR, 60, 'mainnet')
Expand Down
37 changes: 37 additions & 0 deletions src/aggregator/priceFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { AggregatedPriceString } from './vwap'

/**
* Shapes a string-format aggregate for the GraphQL AggregatedPrice type: each
* `*Exact` field is the PostgreSQL numeric text, and the deprecated Float field
* of the same name is derived from it for existing clients.
*/
export function withExactPriceFields(agg: AggregatedPriceString) {
return {
price: Number(agg.price),
priceExact: agg.price,
sdexPrice: Number(agg.sdexPrice),
sdexPriceExact: agg.sdexPrice,
ammPrice: Number(agg.ammPrice),
ammPriceExact: agg.ammPrice,
volume24h: Number(agg.volume24h),
volume24hExact: agg.volume24h,
sdexVolume24h: Number(agg.sdexVolume24h),
sdexVolume24hExact: agg.sdexVolume24h,
ammVolume24h: Number(agg.ammVolume24h),
ammVolume24hExact: agg.ammVolume24h,
vwap1m: Number(agg.vwap1m),
vwap1mExact: agg.vwap1m,
vwap5m: Number(agg.vwap5m),
vwap5mExact: agg.vwap5m,
vwap1h: Number(agg.vwap1h),
vwap1hExact: agg.vwap1h,
vwap24h: Number(agg.vwap24h),
vwap24hExact: agg.vwap24h,
priceChange24h: Number(agg.priceChange24h),
priceChange24hExact: agg.priceChange24h,
sources: agg.sources,
confidence: agg.confidence,
lastTradeAgeSeconds: agg.lastTradeAgeSeconds,
stale: agg.stale,
}
}
42 changes: 37 additions & 5 deletions src/aggregator/vwap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,44 @@ export async function calculateVWAP(
return detailed.num
}

/** VWAP as PostgreSQL numeric text, with no Number round-trip. */
export async function calculateVWAPExact(
pairKey: string,
windowMinutes: number,
network: NetworkName,
source?: 'SDEX' | 'AMM'
): Promise<string> {
const detailed = await calculateVWAPDetailed(pairKey, windowMinutes, network, source)
return detailed.raw
}

export async function calculateOHLCV(
pairKey: string,
windowMinutes: number,
network: NetworkName
): Promise<{
open: number; high: number; low: number; close: number
volume: number; tradeCount: number
}> {
const exact = await calculateOHLCVExact(pairKey, windowMinutes, network)
return {
open: parseFloat(exact.open),
high: parseFloat(exact.high),
low: parseFloat(exact.low),
close: parseFloat(exact.close),
volume: parseFloat(exact.volume),
tradeCount: exact.tradeCount,
}
}

/** OHLCV with prices and volume as PostgreSQL numeric text. */
export async function calculateOHLCVExact(
pairKey: string,
windowMinutes: number,
network: NetworkName
): Promise<{
open: string; high: string; low: string; close: string
volume: string; tradeCount: number
}> {
const result = await pgPool.query(
`SELECT
Expand All @@ -86,11 +117,11 @@ export async function calculateOHLCV(
)
const row = result.rows[0]
return {
open: parseFloat(row?.open ?? '0'),
high: parseFloat(row?.high ?? '0'),
low: parseFloat(row?.low ?? '0'),
close: parseFloat(row?.close ?? '0'),
volume: parseFloat(row?.volume ?? '0'),
open: String(row?.open ?? '0'),
high: String(row?.high ?? '0'),
low: String(row?.low ?? '0'),
close: String(row?.close ?? '0'),
volume: String(row?.volume ?? '0'),
tradeCount: parseInt(row?.trade_count ?? '0', 10),
}
}
Expand Down Expand Up @@ -398,3 +429,4 @@ export async function getAggregatedPrice(
}
}


Loading
Loading