Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ Data is stored in a PostgreSQL database optimized with declarative range partiti
* **`price_points`**: A unified table for all trade events across both SDEX and AMM, range-partitioned by `timestamp` into daily partitions (`price_points_yYYYYmMMdDD`). Automatic background workers pre-create future partitions ahead of need. Partition retention via metadata-only `DROP TABLE` operations is opt-in (`PRICE_POINTS_DROP_EXPIRED=true`, default: off) to safeguard raw historical records until rollups are completed (#231). Query execution plans on recent time windows leverage PostgreSQL partition pruning to scan only active partitions and skip all historical data.
* **`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**.
Expand Down
72 changes: 72 additions & 0 deletions docs/venues.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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. |
| **Phoenix** | `phoenix_amm` | `primary` | **Phoenix AMM**: Constant-product liquidity pools on Soroban. Each pool contract holds its own reserves deposited by LPs, and trades execute against those reserves rather than being routed elsewhere. |
| **DeFindex** | `defindex_vault` | `aggregator` | **DeFindex Vaults**: Yield vaults on Soroban priced per share. A vault holds no market of its own: deposits are allocated to underlying strategies such as lending pools, so any activity it reports belongs to the venues underneath. It is excluded from net volume. |

---

## 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`, `phoenix_amm`). This is the true, non-duplicated ecosystem trading volume.
- **`totals.gross` / `grossVolume`**: Total volume across all reporting venues including aggregators (`aquarius_amm`, `defindex_vault`).
- **`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.
49 changes: 46 additions & 3 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1672,7 +1672,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 and trade-size statistics. `volumeConcentration` is the Herfindahl-Hirschman Index (HHI), calculated as the sum of squared per-trade volume shares across the selected venues and window; it ranges from 0 to 1, with 1 meaning all volume came from one trade. A missing statistic is `null` when there are no trades or no positive volume. Provenance reports the venues, positive-volume trade count, observed sample count, most recent trade timestamp and its age at `endTime`.",
"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 and trade-size statistics. `volumeConcentration` is the Herfindahl-Hirschman Index (HHI), calculated as the sum of squared per-trade volume shares across the selected venues and window; it ranges from 0 to 1, with 1 meaning all volume came from one trade. A missing statistic is `null` when there are no trades or no positive volume. Provenance reports the venues, positive-volume trade count, observed sample count, most recent trade timestamp and its age at `endTime`. Venues are classified as primary markets or aggregators in the registry. `totals.net` / `netVolume` sum primary markets only, while `totals.gross` / `grossVolume` include aggregator venues (e.g. Aquarius) that re-report trades settled elsewhere. `totalVolume` and `tradeCount` report the net figures; the trade-size statistics and provenance cover every venue (gross).",
"security": [],
"parameters": [
{
Expand Down Expand Up @@ -4549,6 +4549,24 @@
}
}
},
"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": [
Expand All @@ -4558,9 +4576,14 @@
"startTime",
"endTime",
"totalVolume",
"netVolume",
"grossVolume",
"totals",
"byVenue",
"venues",
"tradeCount",
"netTradeCount",
"grossTradeCount",
"medianTradeSize",
"topTradeVolumeShare",
"volumeConcentration",
Expand Down Expand Up @@ -4596,8 +4619,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",
Expand All @@ -4613,7 +4648,15 @@
},
"tradeCount": {
"type": "integer",
"description": "Number of positive-volume price point samples in the selected window."
"description": "Number of positive-volume samples from primary markets (equals netTradeCount)."
},
"netTradeCount": {
"type": "integer",
"description": "Number of positive-volume samples from primary markets."
},
"grossTradeCount": {
"type": "integer",
"description": "Number of positive-volume samples across all venues, including aggregators."
},
"medianTradeSize": {
"type": "number",
Expand Down
40 changes: 39 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,12 @@ paths:
when there are no trades or no positive volume. Provenance reports the
venues, positive-volume trade count, observed sample count, most recent
trade timestamp and its age at `endTime`.
Venues are classified as primary markets or aggregators in the registry.
`totals.net` / `netVolume` sum primary markets only, while
`totals.gross` / `grossVolume` include aggregator venues (e.g. Aquarius)
that re-report trades settled elsewhere. `totalVolume` and `tradeCount`
report the net figures; the trade-size statistics and provenance
cover every venue (gross).
security: []
parameters:
- name: asset
Expand Down Expand Up @@ -2886,6 +2892,18 @@ 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:
Expand All @@ -2895,9 +2913,14 @@ components:
- startTime
- endTime
- totalVolume
- netVolume
- grossVolume
- totals
- byVenue
- venues
- tradeCount
- netTradeCount
- grossTradeCount
- medianTradeSize
- topTradeVolumeShare
- volumeConcentration
Expand All @@ -2920,8 +2943,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:
Expand All @@ -2932,7 +2964,13 @@ components:
type: string
tradeCount:
type: integer
description: Number of positive-volume price point samples in the selected window.
description: Number of positive-volume samples from primary markets (equals netTradeCount).
netTradeCount:
type: integer
description: Number of positive-volume samples from primary markets.
grossTradeCount:
type: integer
description: Number of positive-volume samples across all venues, including aggregators.
medianTradeSize:
type: number
nullable: true
Expand Down
30 changes: 30 additions & 0 deletions src/__tests__/venueAdapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
getLastPrice,
resetLastPrice,
setLastPrice,
VENUE_CLASSIFICATIONS,
getVenueClassification,
isPrimaryVenue,
isAggregatorVenue,
getVenueReasoning,
type VenueAdapter,
} from '../ingest/venues'

Expand Down Expand Up @@ -50,6 +55,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')
Expand All @@ -60,6 +66,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')
Expand Down Expand Up @@ -92,6 +121,7 @@ describe('VenueAdapter Interface and Runner (#218)', () => {
const customAdapter: VenueAdapter = {
name: 'CustomDEX',
source: 'custom_dex',
classification: 'primary',
isEnabled: () => false,
getPollIntervalMs: () => 1000,
discover: async () => [],
Expand Down
Loading