Pricing & the price graph
Summary
Section titled “Summary”PriceGraphService.convert(amount, fromTokenId, toTokenId, at) is the
single entry point for cross-token / cross-time conversion. There is
no USD-canonical column in the schema — every price is stored in
its native quote currency. The service walks the implicit graph
defined by token_prices rows: direct → reverse direct → one-hop via
USD / USDT / EUR → (rarely) two-hop. A staleness contract lets
conversions succeed with old prices while flagging the result for
downstream coverage-quality treatment.
What the graph looks like
Section titled “What the graph looks like”Every token_prices row is a directed edge tokenId → baseTokenId
at a particular timestamp and granularity. The “graph” is
implicit — there is no edges table. Hubs are well-known tokens
(USD, USDT, EUR by default) that frequently have edges from many
other tokens.
AAPL ──$── USD ─────────── EUR ──€── BTC │ │ USDT GBPThe routing algorithm
Section titled “The routing algorithm”PriceGraphService.convert(...) tries paths in order:
- Identity. Same token in and out:
(amount, rate=1, stale=false). - Direct lookup at or before
at, preferred granularity. The edge(from → to)at the most recent timestamp wins. - Reverse direct. If
(to → from)exists, use1/price. - One-hop via each hub in order (default
[USD, USDT, EUR], plus the user’s display base when known). First hub whose two legs both resolve wins. - Two-hop via pairs of hubs (only when
maxDepth: 3). Rarely useful; pays for extra lookups. - Otherwise null. Callers must tolerate.
const conversion = await priceGraph.convert( new Decimal('1'), btcTokenId, eurTokenId, new Date('2024-06-01T00:00:00Z'), { preferGranularity: 'daily', maxDepth: 2 });// → { amount: Decimal('57000'), rate: Decimal('57000'), effectiveAt: ..., path: 'direct', stale: false }What the result tells you
Section titled “What the result tells you”| Field | Meaning |
|---|---|
amount | The result, already denominated in toTokenId. |
rate | The effective rate used (per unit of fromToken). |
effectiveAt | Timestamp of the oldest edge consulted — the binding leg. |
path | 'identity', 'direct', 'reverse-direct', 'one-hop-<hubId>', 'two-hop-<hub1>-<hub2>'. |
stale | True when effectiveAt is older than the granularity-appropriate staleness cap. The amount is still returned — callers fold it into coverageQuality rather than dropping the holding and fabricating a chart gap. |
Staleness contract
Section titled “Staleness contract”Two caps:
MAX_INTRADAY_PRICE_AGE_MS— short. An intraday price older than this is stale.MAX_DAILY_PRICE_AGE_MS— much longer. A daily-granularity price can legitimately be days old for thin pairs.
If preferGranularity = 'daily', the service uses the daily cap. The
binding leg of a multi-hop path — whichever leg has the older
timestamp — is what determines staleness, because the chain is only
as fresh as its weakest link.
Hub selection
Section titled “Hub selection”Default hubs: ['USD', 'USDT', 'EUR'], evaluated in order.
- Callers can override via
options.hubSymbols. PortfolioValuationAtTimeServiceautomatically folds the user’s display base into the hub list when a display currency is configured.- The hub-symbol → token-id resolution is cached for the lifetime of the process. Tokens are seeded by migration and stable for the process lifetime; invalidation on restart is fine.
The service deduplicates resolved hub ids before iterating —
configured hub symbols can resolve to the same tokens.id (USD and a
fiat stablecoin aliased to the same token, or a future alias table),
and a repeated id would produce a degenerate path
A → hub → same-hub → B with rate p × 1/p = 1.
PriceLookup — the hot-path optimisation
Section titled “PriceLookup — the hot-path optimisation”The portfolio-value rollup does millions of
convert calls per backfill. Per-call DB lookups would be ruinous.
PriceLookup is a pre-fetched in-memory price index. The rollup
loads every relevant price for the user up front, hands the
PriceLookup in via options.priceLookup, and tryDirect reads
from memory. Pairs not in the lookup defensively fall through to the
DB — so the hot path is still correct if the rollup didn’t preload
something.
Granularities
Section titled “Granularities”Three granularities live in token_prices.granularity:
| Granularity | Source | Use |
|---|---|---|
intraday | Live syncs (CoinGecko paid, Finnhub, exchange tickers). | Recent valuation, the dashboard headline. |
daily | Historical backfill (historical-price-backfill job). | The chart, point-in-time historical valuation. |
tx-exact | Captured at a transaction’s occurredAt. | Cost-basis math against the actual fill price. |
The composite unique key on token_prices is
(tokenId, baseTokenId, timestamp, granularity) so the same
timestamp can carry both a daily close and an intraday candle without
collision.
Provider routing
Section titled “Provider routing”PricingProviderRouter decides which upstream to ask. Adapters
(PricingProviderAdapter) wrap each provider; the router falls
through to the next on failure. The
unpriceableUntil cooldown gate on tokens
prevents the historical backfill from retrying providers we’ve
already established can’t supply prices for a given token.
The PricingFailureCacher records “no price found at time X for
token Y” with a short TTL so the next pass doesn’t re-ask every
provider for the same gap.