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
62 changes: 62 additions & 0 deletions docs/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Lens exposes a high-performance GraphQL API alongside its REST interface, powere
- [`getPriceHistory`](#3-getpricehistory)
- [`listPairs`](#4-listpairs)
- [`screener`](#5-screener)
- [`volumes` and `spreads`](#6-volumes-and-spreads)
4. [Live Price Subscriptions (`graphql-transport-ws`)](#live-price-subscriptions-graphql-transport-ws)
- [Protocol Handshake Walkthrough](#protocol-handshake-walkthrough)
- [Network Filtering Semantics](#network-filtering-semantics)
Expand Down Expand Up @@ -71,6 +72,8 @@ type Query {
getPriceHistory(assetA: String!, assetB: String!, window: String!, from: String, to: String, limit: Int): [PriceBucket]
listPairs: [String]!
screener(filters: ScreenerFilters, sortBy: String = "volume", order: ScreenerSortOrder = DESC, cursor: String, limit: Int = 20): ScreenerPage!
volumes(asset: String!, window: String, network: String): VolumeReport!
spreads(asset: String!, window: String, network: String): SpreadReport!
}

type Subscription {
Expand Down Expand Up @@ -452,6 +455,65 @@ whether another page exists.

---

### 6. `volumes` and `spreads`

GraphQL counterparts of REST `GET /volumes/:asset` and `GET /spreads/:asset`.
Each resolver calls the REST route's own implementation (`computeVolumes` in
`src/routes/volumes.ts`, `computeSpreads` in `src/routes/spreads.ts`), so the
figures cannot drift between the two surfaces.

| Argument | `volumes` | `spreads` |
|---|---|---|
| `asset` | required, e.g. `XLM` | required, e.g. `XLM` |
| `window` | `24h` (default), `7d`, `30d` | `5m` (default), `15m`, `1h`, `24h` |
| `network` | `testnet` / `mainnet`, defaults to the active network | `testnet` / `mainnet`, defaults to the active network |

An unrecognised `window` or `network` fails with `INVALID_INPUT` and the same
message REST returns with its 400. The network answered for is echoed in
`network`; the two chains are never pooled.

Per-venue results are a typed list of `VenueQuote`, so a client can select
just the fields it wants per venue:

- `volumes.venues`: `venue`, `volume` (in the asset's own units, summed across
pairs) and `aggregator`. This is REST's `byVenue` map as a list, in REST's
`venues` order. Every other REST field (`totals`, `netVolume`,
`grossVolume`, `totalVolume`, trade counts, trade-size statistics, `status`,
`provenance`) is returned under the same name.
- `spreads.venues`: one entry per (venue, pair), tightest first, with `pairKey`,
`bid`, `ask`, `mid`, `spreadBps`, `spreadPct`, `observations` and `lastSeen`.
`tightest` is the first of them, or `null` when no venue quoted the asset.

Fields that belong to the other query are `null` on a `VenueQuote`.

#### Query Definition
```graphql
query VenueViews($asset: String!, $network: String) {
volumes(asset: $asset, window: "24h", network: $network) {
network
totals { net gross }
venues { venue volume aggregator }
}
spreads(asset: $asset, window: "5m", network: $network) {
network
tightest { venue pairKey spreadBps }
venues { venue pairKey bid ask spreadBps }
}
}
```

#### Runnable `curl` Command
```bash
curl -s -X POST http://localhost:3002/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query VenueViews($asset: String!, $network: String) { volumes(asset: $asset, network: $network) { network totals { net gross } venues { venue volume aggregator } } spreads(asset: $asset, network: $network) { network tightest { venue pairKey spreadBps } } }",
"variables": { "asset": "XLM", "network": "testnet" }
}'
```

---

## Live Price Subscriptions (`graphql-transport-ws`)

Live price updates are streamed over WebSocket using the [`graphql-transport-ws`](https://github.com/enisdenjo/graphql-ws) subprotocol. The client must negotiate this subprotocol during the WebSocket handshake.
Expand Down
127 changes: 127 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,117 @@ type ScreenerPage {
hasMore: Boolean!
}

"""
One venue's figures in a `volumes` or `spreads` result. Both queries
return venues as a list of this type so a caller can select only the fields
it wants per venue.

Which fields are set depends on the query, and mirrors the matching REST
route field for field:

- `volumes`: `venue`, `volume` (the asset's traded volume on that venue,
in the asset's own units, summed across every pair) and `aggregator`.
The spread fields are null.
- `spreads`: one entry per (venue, pair): `venue`, `pairKey`, `bid`,
`ask`, `mid`, `spreadBps`, `spreadPct`, `observations`,
`lastSeen`. `volume` is null.
"""
type VenueQuote {
venue: String!

"""
Whether the venue is an aggregator re-reporting primary-market trades (volumes only).
"""
aggregator: Boolean

"""Traded volume in the asset's own units over the window (volumes only)."""
volume: Float

"""
The pair this quote is for (spreads only): two pairs on one venue are different books.
"""
pairKey: String

"""Lowest print in the window (spreads only)."""
bid: Float

"""Highest print in the window (spreads only)."""
ask: Float
mid: Float

"""(ask - bid) / mid * 10000 (spreads only)."""
spreadBps: Float

"""spreadBps / 100 (spreads only)."""
spreadPct: Float
observations: Int
lastSeen: String
}

"""
Labelled net (primary markets only) and gross (including aggregators) volume.
"""
type VolumeTotals {
net: Float!
gross: Float!
}

type VolumeProvenance {
venues: [String!]!
sampleCount: Int!
observedSampleCount: Int!
latestTradeAt: String
dataAgeSeconds: Int
}

"""
Cross-venue traded volume for an asset: the same figures as REST
GET /volumes/:asset, with the per-venue breakdown (`byVenue` on REST) as a
typed `venues` list.
"""
type VolumeReport {
asset: String!
network: String!
window: String!
startTime: String!
endTime: String!
totals: VolumeTotals!
netVolume: Float!
grossVolume: Float!

"""Alias for netVolume, as on REST."""
totalVolume: Float!

"""Per-venue volume, ordered by venue name like REST's `venues`."""
venues: [VenueQuote!]!
tradeCount: Int!
netTradeCount: Int!
grossTradeCount: Int!
medianTradeSize: Float
topTradeVolumeShare: Float
volumeConcentration: Float
concentrationMeasure: String!

"""ok, no_trades or no_positive_volume."""
status: String!
provenance: VolumeProvenance!
}

"""
Per-(venue, pair) spreads for an asset: the same figures as REST
GET /spreads/:asset. `venues` is tightest first; `tightest` is null when no
venue quoted the asset in the window.
"""
type SpreadReport {
asset: String!
network: String!
window: String!
startTime: String!
endTime: String!
venues: [VenueQuote!]!
tightest: VenueQuote
}

type Query {
"""
Aggregated price for a pair. Three outcomes are kept distinct:
Expand Down Expand Up @@ -196,6 +307,22 @@ type Query {
rejected.
"""
screener(filters: ScreenerFilters, sortBy: String = "volume", order: ScreenerSortOrder = DESC, cursor: String, limit: Int = 20): ScreenerPage!

"""
Cross-venue traded volume for an asset. Shares its implementation with
REST GET /volumes/:asset: same windows (24h, 7d, 30d; default 24h), same
figures. `network` (testnet or mainnet) defaults to the active network;
any other value fails with INVALID_INPUT, as REST answers 400.
"""
volumes(asset: String!, window: String, network: String): VolumeReport!

"""
Per-(venue, pair) bid/ask spreads for an asset. Shares its implementation
with REST GET /spreads/:asset: same windows (5m, 15m, 1h, 24h; default 5m),
same figures. `network` (testnet or mainnet) defaults to the active
network; any other value fails with INVALID_INPUT, as REST answers 400.
"""
spreads(asset: String!, window: String, network: String): SpreadReport!
}

type Subscription {
Expand Down
Loading