From 2bee02d234dc530fa037b11f9060358022a345de Mon Sep 17 00:00:00 2001 From: gospeltout Date: Sat, 17 Oct 2026 12:44:15 +0100 Subject: [PATCH] fix: classify venues and report net vs gross volume totals --- docs/architecture.md | 3 +- docs/venues.md | 70 +++++++++++++++++ openapi.json | 52 ++++++++++++- openapi.yaml | 36 ++++++++- src/__tests__/venueAdapters.test.ts | 30 +++++++ src/__tests__/volumes.test.ts | 84 ++++++++++++++++++++ src/ingest/venues/amm.ts | 1 + src/ingest/venues/aquarius.ts | 1 + src/ingest/venues/index.ts | 117 ++++++++++++++++++++++++++++ src/ingest/venues/sdex.ts | 1 + src/ingest/venues/soroswap.ts | 1 + src/ingest/venues/types.ts | 3 + src/routes/volumes.ts | 47 ++++++++--- 13 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 docs/venues.md diff --git a/docs/architecture.md b/docs/architecture.md index 24b11167..a24b17b2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,11 +62,12 @@ Data is stored in a PostgreSQL database optimized with TimescaleDB for time-seri * **`price_points`**: A unified table for all trade events across both SDEX and AMM. This enables high-performance VWAP (Volume Weighted Average Price) calculations over various time windows (1m, 5m, 1h, 24h). * **`pool_snapshots`**: Stores the reserve state of AMM liquidity pools, which is critical for slippage estimation and constant-product price calculations. -### 3. Aggregation & Routing (`bestRoute`) +### 3. Aggregation & Routing (`bestRoute` & Volume) When a price is requested, the `bestRoute` engine compares the available liquidity on both SDEX and AMM: * **SDEX Pricing**: Fetches real-time path payment quotes from Horizon to determine the effective rate for a specific trade size. * **AMM Pricing**: Uses the constant-product formula (`x * y = k`) against the latest pool snapshots to estimate the output and slippage. * **Optimal Routing**: Compares the rates and recommends the best execution path (SDEX, AMM, or a SPLIT for large orders) to minimize slippage for the user. +* **Venue Classification & Volume Accounting**: Venues in the venue registry are categorized as either primary markets (SDEX, native AMM, Soroswap) or aggregators (Aquarius). Aggregate volume metrics report labelled net (primary markets) and gross figures to eliminate trade double-counting. See [venues.md](./venues.md) for details. ### 4. API Layer & x402 Payment Gate The system exposes both REST and GraphQL interfaces. To monetize the high-fidelity data, Lens implements the **x402 protocol**. diff --git a/docs/venues.md b/docs/venues.md new file mode 100644 index 00000000..2515e3b8 --- /dev/null +++ b/docs/venues.md @@ -0,0 +1,70 @@ +# Venue Classification & Volume Accounting + +## Overview + +Lens ingests trade and pricing data across multiple venues on the Stellar network. When computing aggregate volume metrics (e.g. `GET /volumes/:asset`), summing reported volumes across all venues naively leads to severe double-counting. + +Aggregator venues that route through or quote underlying decentralized exchanges report the exact same underlying trades that are also ingested from the primary venue. If an aggregator routes an order through a Stellar native liquidity pool or SDEX orderbook, counting the volume on both venues overstates actual economic activity. + +To solve this, Lens classifies every venue in the venue registry as either a **primary market** or an **aggregator**. + +--- + +## Classification Taxonomy + +| Classification | Definition | Volume Accounting Impact | +| :--- | :--- | :--- | +| **`primary`** | An independent market holding its own resting liquidity (orderbook or isolated pool reserves) where trades execute natively. | Counted toward **net** and **gross** volume totals. | +| **`aggregator`** | A router, wrapper, or meta-venue that quotes and executes trades against underlying primary markets. | Excluded from **net** volume (to prevent double-counting); included in **gross** volume. | + +--- + +## Venue Registry & Classification Reasoning + +| Venue Name | Source Identifier | Classification | Technical Reasoning | +| :--- | :--- | :--- | :--- | +| **SDEX** | `SDEX` | `primary` | **Stellar Decentralized Exchange**: Stellar's native protocol-level Central Limit Order Book (CLOB). Resting limit orders are stored directly in the Stellar ledger state. Trades execute as native on-ledger operations matching resting buy and sell offers, establishing primary price discovery with independent orderbook liquidity. | +| **AMM** | `AMM` | `primary` | **Stellar Native Liquidity Pools (CAP-0038)**: Protocol-level constant-product liquidity pools. Liquidity providers deposit assets directly into on-chain pool reserves. Trades execute against pool reserves directly on the ledger. Each trade represents primary liquidity consumption in the pool. | +| **Soroswap** | `soroswap_amm` | `primary` | **Soroswap AMM**: Automated market maker smart contracts deployed on the Soroban smart contract platform. Pairs hold isolated pool reserves locked in Soroban contract instances deposited directly by LPs. Trades execute against the contracts' own reserves rather than routing to other venues. | +| **Aquarius** | `aquarius_amm` | `aggregator` | **Aquarius**: On classic Stellar, Aquarius acts as a liquidity aggregator and incentive layer. It quotes and routes trades through underlying Stellar native AMM liquidity pools (`pool_hash`) and SDEX orderbooks rather than settling against an isolated, independent liquidity source. Counting Aquarius volume alongside SDEX and native AMM trades double-counts the same underlying trades. | + +--- + +## API Response Format (`/volumes/:asset`) + +The aggregated volume endpoint (`GET /volumes/:asset`) reports both figures with explicit labels: + +```json +{ + "asset": "XLM", + "network": "testnet", + "window": "24h", + "startTime": "2026-10-16T12:00:00.000Z", + "endTime": "2026-10-17T12:00:00.000Z", + "totals": { + "net": 1000000.0, + "gross": 1500000.0 + }, + "netVolume": 1000000.0, + "grossVolume": 1500000.0, + "totalVolume": 1000000.0, + "byVenue": { + "SDEX": 600000.0, + "AMM": 400000.0, + "aquarius_amm": 500000.0 + }, + "venues": [ + "SDEX", + "AMM", + "aquarius_amm" + ], + "tradeCount": 1200, + "netTradeCount": 1200, + "grossTradeCount": 1750 +} +``` + +- **`totals.net` / `netVolume`**: Traded volume summed across primary markets only (`SDEX`, `AMM`, `soroswap_amm`). This is the true, non-duplicated ecosystem trading volume. +- **`totals.gross` / `grossVolume`**: Total volume across all reporting venues including aggregators (`aquarius_amm`). +- **`totalVolume`**: Preserved for backward compatibility, reporting `netVolume` to ensure callers do not overstate real activity. +- **`byVenue`**: Granular breakdown of reported volume by venue source string. diff --git a/openapi.json b/openapi.json index cb6441b7..ecc26762 100644 --- a/openapi.json +++ b/openapi.json @@ -1059,7 +1059,7 @@ "get": { "operationId": "getVolumes", "summary": "Get cross-venue traded volume for an asset", - "description": "Sums traded volume for `asset` across every pair and venue it appears in, measured in the asset's own units, with a per-venue breakdown.", + "description": "Sums traded volume for `asset` across every pair and venue it appears in, measured in the asset's own units, with a per-venue breakdown. Venues are classified as primary markets or aggregators in the registry. Reports labelled net volume (summed across primary markets) and gross volume (including aggregator venues like Aquarius that route through other venues) to eliminate double-counting of trades.", "security": [], "parameters": [ { @@ -2524,14 +2524,36 @@ } } }, + "VolumeTotals": { + "type": "object", + "required": [ + "net", + "gross" + ], + "additionalProperties": false, + "properties": { + "net": { + "type": "number", + "description": "Volume summed across primary markets only (excluding aggregator double-counts)" + }, + "gross": { + "type": "number", + "description": "Gross volume summed across all venues including aggregators" + } + } + }, "VolumeResponse": { "type": "object", "required": [ "asset", + "network", "window", "startTime", "endTime", "totalVolume", + "netVolume", + "grossVolume", + "totals", "byVenue", "venues", "tradeCount" @@ -2541,6 +2563,9 @@ "asset": { "type": "string" }, + "network": { + "type": "string" + }, "window": { "type": "string", "enum": [ @@ -2557,8 +2582,20 @@ "type": "string", "format": "date-time" }, + "totals": { + "$ref": "#/components/schemas/VolumeTotals" + }, + "netVolume": { + "type": "number", + "description": "Volume summed across primary markets only (excluding aggregator double-counts)" + }, + "grossVolume": { + "type": "number", + "description": "Gross volume summed across all venues including aggregators" + }, "totalVolume": { - "type": "number" + "type": "number", + "description": "Primary volume total (alias for netVolume for backward compatibility)" }, "byVenue": { "type": "object", @@ -2573,7 +2610,16 @@ } }, "tradeCount": { - "type": "integer" + "type": "integer", + "description": "Count of trades from primary markets" + }, + "netTradeCount": { + "type": "integer", + "description": "Count of trades from primary markets" + }, + "grossTradeCount": { + "type": "integer", + "description": "Count of trades across all venues including aggregators" } } }, diff --git a/openapi.yaml b/openapi.yaml index 9d511d0b..26351a38 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -683,6 +683,10 @@ paths: description: >- Sums traded volume for `asset` across every pair and venue it appears in, measured in the asset's own units, with a per-venue breakdown. + Venues are classified as primary markets or aggregators in the registry. + Reports labelled net volume (summed across primary markets) and gross volume + (including aggregator venues like Aquarius that route through other venues) + to eliminate double-counting of trades. security: [] parameters: - name: asset @@ -1595,13 +1599,27 @@ components: type: string enum: [iqr, modified_zscore] + VolumeTotals: + type: object + required: [net, gross] + additionalProperties: false + properties: + net: + type: number + description: Volume summed across primary markets only (excluding aggregator double-counts) + gross: + type: number + description: Gross volume summed across all venues including aggregators + VolumeResponse: type: object - required: [asset, window, startTime, endTime, totalVolume, byVenue, venues, tradeCount] + required: [asset, network, window, startTime, endTime, totalVolume, netVolume, grossVolume, totals, byVenue, venues, tradeCount] additionalProperties: false properties: asset: type: string + network: + type: string window: type: string enum: [24h, 7d, 30d] @@ -1611,8 +1629,17 @@ components: endTime: type: string format: date-time + totals: + $ref: '#/components/schemas/VolumeTotals' + netVolume: + type: number + description: Volume summed across primary markets only (excluding aggregator double-counts) + grossVolume: + type: number + description: Gross volume summed across all venues including aggregators totalVolume: type: number + description: Primary volume total (alias for netVolume for backward compatibility) byVenue: type: object additionalProperties: @@ -1623,6 +1650,13 @@ components: type: string tradeCount: type: integer + description: Count of trades from primary markets + netTradeCount: + type: integer + description: Count of trades from primary markets + grossTradeCount: + type: integer + description: Count of trades across all venues including aggregators SpreadVenue: type: object diff --git a/src/__tests__/venueAdapters.test.ts b/src/__tests__/venueAdapters.test.ts index 4301c23f..962ee991 100644 --- a/src/__tests__/venueAdapters.test.ts +++ b/src/__tests__/venueAdapters.test.ts @@ -11,6 +11,11 @@ import { getLastPrice, resetLastPrice, setLastPrice, + VENUE_CLASSIFICATIONS, + getVenueClassification, + isPrimaryVenue, + isAggregatorVenue, + getVenueReasoning, type VenueAdapter, } from '../ingest/venues' @@ -44,6 +49,7 @@ describe('VenueAdapter Interface and Runner (#218)', () => { for (const adapter of venueAdapters) { expect(typeof adapter.name).toBe('string') expect(typeof adapter.source).toBe('string') + expect(['primary', 'aggregator']).toContain(adapter.classification) expect(typeof adapter.isEnabled).toBe('function') expect(typeof adapter.getPollIntervalMs).toBe('function') expect(typeof adapter.discover).toBe('function') @@ -54,6 +60,29 @@ describe('VenueAdapter Interface and Runner (#218)', () => { } }) + it('classifies SDEX, AMM, and Soroswap as primary markets and Aquarius as an aggregator', () => { + expect(sdexAdapter.classification).toBe('primary') + expect(ammAdapter.classification).toBe('primary') + expect(soroswapAdapter.classification).toBe('primary') + expect(aquariusAdapter.classification).toBe('aggregator') + + expect(isPrimaryVenue('SDEX')).toBe(true) + expect(isPrimaryVenue('AMM')).toBe(true) + expect(isPrimaryVenue('soroswap_amm')).toBe(true) + expect(isPrimaryVenue('aquarius_amm')).toBe(false) + + expect(isAggregatorVenue('aquarius_amm')).toBe(true) + expect(isAggregatorVenue('Aquarius')).toBe(true) + expect(isAggregatorVenue('SDEX')).toBe(false) + expect(isAggregatorVenue('AMM')).toBe(false) + + // Documented reasoning must be present + expect(getVenueReasoning('SDEX')).toMatch(/order book|CLOB/i) + expect(getVenueReasoning('AMM')).toMatch(/liquidity pool|reserves/i) + expect(getVenueReasoning('soroswap_amm')).toMatch(/Soroban/i) + expect(getVenueReasoning('aquarius_amm')).toMatch(/aggregator|double-count/i) + }) + it('preserves exact source strings for backward compatibility', () => { expect(sdexAdapter.source).toBe('SDEX') expect(ammAdapter.source).toBe('AMM') @@ -82,6 +111,7 @@ describe('VenueAdapter Interface and Runner (#218)', () => { const customAdapter: VenueAdapter = { name: 'CustomDEX', source: 'custom_dex', + classification: 'primary', isEnabled: () => false, getPollIntervalMs: () => 1000, discover: async () => [], diff --git a/src/__tests__/volumes.test.ts b/src/__tests__/volumes.test.ts index 5262997d..50409625 100644 --- a/src/__tests__/volumes.test.ts +++ b/src/__tests__/volumes.test.ts @@ -44,6 +44,90 @@ describe('GET /volumes/:asset', () => { await app.close() }) + it('separates net (primary markets) and gross (including aggregators) totals when traded on primary and aggregator venues', async () => { + mockQuery.mockResolvedValue({ + rows: [ + { source: 'SDEX', volume: '100.0', trade_count: 2 }, + { source: 'aquarius_amm', volume: '50.0', trade_count: 1 }, + ], + }) + + 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() + expect(body.asset).toBe('XLM') + expect(body.byVenue).toEqual({ SDEX: 100.0, aquarius_amm: 50.0 }) + expect(body.totals).toEqual({ + net: 100.0, + gross: 150.0, + }) + expect(body.netVolume).toBe(100.0) + expect(body.grossVolume).toBe(150.0) + // totalVolume defaults to net volume to avoid overstating real activity + expect(body.totalVolume).toBe(100.0) + expect(body.tradeCount).toBe(2) + expect(body.netTradeCount).toBe(2) + expect(body.grossTradeCount).toBe(3) + await app.close() + }) + + it('aggregates across multiple primaries (SDEX, AMM, Soroswap) alongside an aggregator (Aquarius)', async () => { + mockQuery.mockResolvedValue({ + rows: [ + { source: 'SDEX', volume: '100.0', trade_count: 5 }, + { source: 'AMM', volume: '200.0', trade_count: 8 }, + { source: 'soroswap_amm', volume: '50.0', trade_count: 2 }, + { source: 'aquarius_amm', volume: '80.0', trade_count: 3 }, + ], + }) + + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/volumes/USDC?window=24h' }) + + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.asset).toBe('USDC') + expect(body.totals.net).toBe(350.0) + expect(body.totals.gross).toBe(430.0) + expect(body.netVolume).toBe(350.0) + expect(body.grossVolume).toBe(430.0) + expect(body.totalVolume).toBe(350.0) + expect(body.tradeCount).toBe(15) + expect(body.netTradeCount).toBe(15) + expect(body.grossTradeCount).toBe(18) + expect(body.byVenue).toEqual({ + SDEX: 100.0, + AMM: 200.0, + soroswap_amm: 50.0, + aquarius_amm: 80.0, + }) + await app.close() + }) + + it('returns zero net volume if only aggregator trades exist', async () => { + mockQuery.mockResolvedValue({ + rows: [ + { source: 'aquarius_amm', volume: '75.0', trade_count: 4 }, + ], + }) + + 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() + expect(body.totals).toEqual({ net: 0, gross: 75.0 }) + expect(body.netVolume).toBe(0) + expect(body.grossVolume).toBe(75.0) + expect(body.totalVolume).toBe(0) + expect(body.tradeCount).toBe(0) + expect(body.netTradeCount).toBe(0) + expect(body.grossTradeCount).toBe(4) + await app.close() + }) + it('defaults to the 24h window', async () => { mockQuery.mockResolvedValue({ rows: [] }) const app = await buildApp() diff --git a/src/ingest/venues/amm.ts b/src/ingest/venues/amm.ts index b715adb0..a43b9584 100644 --- a/src/ingest/venues/amm.ts +++ b/src/ingest/venues/amm.ts @@ -233,6 +233,7 @@ export async function ingestPair(pair: WatchedPair, network: NetworkName = activ export const ammAdapter: VenueAdapter = { name: 'AMM', source: 'AMM', + classification: 'primary', isEnabled(_network: NetworkName): boolean { return true diff --git a/src/ingest/venues/aquarius.ts b/src/ingest/venues/aquarius.ts index 3c2255cd..276ad760 100644 --- a/src/ingest/venues/aquarius.ts +++ b/src/ingest/venues/aquarius.ts @@ -108,6 +108,7 @@ export async function ingestAquariusPair( export const aquariusAdapter: VenueAdapter = { name: 'Aquarius', source: 'aquarius_amm', + classification: 'aggregator', isEnabled(network: NetworkName): boolean { return getNetworkConfig(network).aquarius.enabled diff --git a/src/ingest/venues/index.ts b/src/ingest/venues/index.ts index 0ea72a28..cdf09cab 100644 --- a/src/ingest/venues/index.ts +++ b/src/ingest/venues/index.ts @@ -22,3 +22,120 @@ export const venueAdapters: VenueAdapter[] = [ soroswapAdapter, aquariusAdapter, ] + +export interface VenueClassificationInfo { + name: string + source: string + classification: import('./types').VenueClassification + reasoning: string +} + +/** + * Canonical registry of venue classifications and technical reasoning. + * + * Every venue ingested into Lens is classified as either: + * - 'primary': An original, independent execution venue holding its own resting + * liquidity (e.g. orderbook or isolated AMM pool reserves). + * - 'aggregator': A routing or wrapper venue that executes trades against underlying + * primary markets (such as classic SDEX orderbooks or native AMM pools). Counting + * aggregator volume alongside primary venue volume double-counts the same underlying trades. + */ +export const VENUE_CLASSIFICATIONS: Record = { + SDEX: { + name: 'SDEX', + source: 'SDEX', + classification: 'primary', + reasoning: + "Stellar's native protocol-level Central Limit Order Book (CLOB). Resting limit offers match natively on the ledger, establishing independent liquidity and primary price discovery.", + }, + AMM: { + name: 'AMM', + source: 'AMM', + classification: 'primary', + reasoning: + 'Stellar native on-chain liquidity pools (CAP-0038). Liquidity providers deposit assets directly into on-chain pools, and trades execute against constant-product pool reserves.', + }, + soroswap_amm: { + name: 'Soroswap', + source: 'soroswap_amm', + classification: 'primary', + reasoning: + 'Automated market maker smart contracts on Soroban. Pairs hold independent pool reserves deposited directly into Soroban contract instances.', + }, + Soroswap: { + name: 'Soroswap', + source: 'soroswap_amm', + classification: 'primary', + reasoning: + 'Automated market maker smart contracts on Soroban. Pairs hold independent pool reserves deposited directly into Soroban contract instances.', + }, + aquarius_amm: { + name: 'Aquarius', + source: 'aquarius_amm', + classification: 'aggregator', + reasoning: + 'Aquarius operates as an aggregator/liquidity layer on Stellar, quoting and routing through underlying Stellar native AMM pools and SDEX orderbooks. Counting its trades alongside primary venues double-counts the same underlying trade executions.', + }, + Aquarius: { + name: 'Aquarius', + source: 'aquarius_amm', + classification: 'aggregator', + reasoning: + 'Aquarius operates as an aggregator/liquidity layer on Stellar, quoting and routing through underlying Stellar native AMM pools and SDEX orderbooks. Counting its trades alongside primary venues double-counts the same underlying trade executions.', + }, +} + +/** + * Returns the classification ('primary' | 'aggregator') for a venue by source or name. + * Defaults to 'primary' for unknown venues to ensure primary markets are not omitted. + */ +export function getVenueClassification(sourceOrName: string): import('./types').VenueClassification { + if (!sourceOrName) return 'primary' + const match = VENUE_CLASSIFICATIONS[sourceOrName] + if (match) return match.classification + + const lower = sourceOrName.toLowerCase() + for (const [key, info] of Object.entries(VENUE_CLASSIFICATIONS)) { + if (key.toLowerCase() === lower || info.name.toLowerCase() === lower || info.source.toLowerCase() === lower) { + return info.classification + } + } + + const adapter = venueAdapters.find( + (a) => a.source.toLowerCase() === lower || a.name.toLowerCase() === lower, + ) + if (adapter) return adapter.classification + + return 'primary' +} + +/** + * Determines whether a venue is a primary market. + */ +export function isPrimaryVenue(sourceOrName: string): boolean { + return getVenueClassification(sourceOrName) === 'primary' +} + +/** + * Determines whether a venue is an aggregator. + */ +export function isAggregatorVenue(sourceOrName: string): boolean { + return getVenueClassification(sourceOrName) === 'aggregator' +} + +/** + * Retrieves the documented classification reasoning for a given venue. + */ +export function getVenueReasoning(sourceOrName: string): string | undefined { + if (!sourceOrName) return undefined + const match = VENUE_CLASSIFICATIONS[sourceOrName] + if (match) return match.reasoning + + const lower = sourceOrName.toLowerCase() + for (const [key, info] of Object.entries(VENUE_CLASSIFICATIONS)) { + if (key.toLowerCase() === lower || info.name.toLowerCase() === lower || info.source.toLowerCase() === lower) { + return info.reasoning + } + } + return undefined +} diff --git a/src/ingest/venues/sdex.ts b/src/ingest/venues/sdex.ts index e8adc156..9fc0942b 100644 --- a/src/ingest/venues/sdex.ts +++ b/src/ingest/venues/sdex.ts @@ -168,6 +168,7 @@ export async function ingestPair(pair: WatchedPair, network: NetworkName = activ export const sdexAdapter: VenueAdapter = { name: 'SDEX', source: 'SDEX', + classification: 'primary', isEnabled(_network: NetworkName): boolean { return true diff --git a/src/ingest/venues/soroswap.ts b/src/ingest/venues/soroswap.ts index 1b2b2b42..4787bd05 100644 --- a/src/ingest/venues/soroswap.ts +++ b/src/ingest/venues/soroswap.ts @@ -345,6 +345,7 @@ export async function ingestPair( export const soroswapAdapter: VenueAdapter = { name: 'Soroswap', source: 'soroswap_amm', + classification: 'primary', isEnabled(network: NetworkName): boolean { return getNetworkConfig(network).soroswap.enabled diff --git a/src/ingest/venues/types.ts b/src/ingest/venues/types.ts index c42dee85..ec93a347 100644 --- a/src/ingest/venues/types.ts +++ b/src/ingest/venues/types.ts @@ -15,6 +15,8 @@ export interface VenuePricePoint { eventId?: string } +export type VenueClassification = 'primary' | 'aggregator' + /** * Universal interface for all venue adapters (SDEX, Stellar AMM, Soroswap, Aquarius, etc.). * @@ -27,6 +29,7 @@ export interface VenuePricePoint { export interface VenueAdapter { readonly name: string readonly source: string + readonly classification: VenueClassification /** * Whether this venue adapter is enabled on the given network. diff --git a/src/routes/volumes.ts b/src/routes/volumes.ts index 802ba03d..e70bb6e3 100644 --- a/src/routes/volumes.ts +++ b/src/routes/volumes.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify' import { pgPool } from '../db' import { activeNetwork, type NetworkName } from '../config' +import { isPrimaryVenue } from '../ingest/venues' // Supported windows → lookback in hours. const WINDOW_HOURS = { @@ -19,10 +20,15 @@ const WINDOWS = Object.keys(WINDOW_HOURS) as VolumeWindow[] * GET /volumes/:asset?window=24h|7d|30d&network=testnet|mainnet * * Sums traded volume for `asset` across every pair it appears in and across all - * venues (SDEX, AMM, …) over the requested window. Volume is measured in the - * asset's own units: when it is the base asset of a pair we count `base_volume`, - * when it is the counter asset we count `counter_volume`. The response breaks the - * total down per venue and also returns the cross-venue sum. + * venues (SDEX, AMM, Soroswap, Aquarius, …) over the requested window. Volume is + * measured in the asset's own units: when it is the base asset of a pair we count + * `base_volume`, when it is the counter asset we count `counter_volume`. + * + * Venues are classified as either primary markets (e.g. SDEX, AMM, Soroswap) or + * aggregators (e.g. Aquarius) that route trades through underlying primary venues. + * To prevent double-counting of trades, the response reports labelled net totals + * (summed across primary venues) and gross totals (including aggregators), alongside + * a per-venue breakdown. * * Only one network's rows are summed: testnet and mainnet volume added together * is not a volume of anything. `network` defaults to this process's own network, @@ -74,14 +80,22 @@ export async function registerVolumeRoutes(app: FastifyInstance) { ) const byVenue: Record = {} - let totalVolume = 0 - let tradeCount = 0 + let netVolume = 0 + let grossVolume = 0 + let netTradeCount = 0 + let grossTradeCount = 0 for (const row of rows) { const volume = parseFloat(row.volume) || 0 + const trades = Number(row.trade_count) || 0 byVenue[row.source] = volume - totalVolume += volume - tradeCount += Number(row.trade_count) || 0 + grossVolume += volume + grossTradeCount += trades + + if (isPrimaryVenue(row.source)) { + netVolume += volume + netTradeCount += trades + } } return { @@ -90,12 +104,21 @@ export async function registerVolumeRoutes(app: FastifyInstance) { window, startTime: startTime.toISOString(), endTime: endTime.toISOString(), - // Cross-venue sum. - totalVolume, - // Per-venue breakdown that the total is summed from. + // Labelled net and gross totals to eliminate aggregator double-counting. + totals: { + net: netVolume, + gross: grossVolume, + }, + netVolume, + grossVolume, + // Backward-compatible alias for net volume. + totalVolume: netVolume, + // Per-venue breakdown that the totals are summed from. byVenue, venues: Object.keys(byVenue), - tradeCount, + tradeCount: netTradeCount, + netTradeCount, + grossTradeCount, } } catch (err) { return reply