Skip to content

Pricing & the price graph

PriceReader is the only reader of token_prices. A caller hands it every (token, instant) it needs and a base currency, and PriceReader.series(asks, baseTokenId) returns a series that answers priceAt(tokenId, at) from memory; PriceReader.at is the one-instant form. There is no USD-canonical column in the schema: every price is stored in its native quote currency, and the engine routes between them, directly, inverted, through a hub (USD, USDT, EUR) or through a currency the asset is quoted in. A stale answer is still returned, flagged for coverage quality.

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) that have edges from many other tokens.

AAPL ──$── USD ─────────── EUR ──€── BTC
│ │
USDT GBP

priceAt (packages/business/domain/src/engine/price-at.ts) builds every route it can for the asset, each leg from the nearest reading at or before the instant asked:

  1. Identity. The asset is the base: price 1, never stale.
  2. Direct. A reading asset → base.
  3. Inverse. A reading base → asset, as 1 / price.
  4. Through each hub, in PRICE_HUBS order: asset to hub, then hub to base, each leg in whichever direction has the later reading.
  5. Through a quote currency: a currency the asset has readings in (a stock quoted in CHF, say), then that currency to the base, directly or through a hub.

The freshest route wins: the one whose oldest leg is latest. A tie goes to the earlier route in that list, whatever the granularity of its readings. So a stored row in the user’s base never beats a newer reading through a hub (SC-1477). A route passes through one hub at most, or through a quote currency and then at most one hub, and an asset with no route is null: callers must tolerate it.

Of an asset’s prices a person typed (source starting with manual), only the latest stands, whatever currency it was typed in. An older typed price in the base cannot beat a correction typed in another currency.

const prices = await priceReader.series(
[{ tokenId: btcTokenId, at: new Date('2024-06-01T23:59:59.999Z') }],
eurTokenId
);
const answer = prices.priceAt(btcTokenId, new Date('2024-06-01T23:59:59.999Z'));
// → { price, readingAt, path, stale, source }, or null when unpriced

priceAt throws a RangeError for a (token, instant) the series was not asked for, so a missing ask fails loudly rather than reading as unpriced.

FieldMeaning
priceOne unit of the asset, in the base.
readingAtTimestamp of the oldest reading the route used: the binding leg.
path'identity', 'direct', 'inverse', 'hub:<hubId>', 'quote:<currencyId>' or 'quote:<currencyId>:<hubId>'.
staleTrue when any leg’s reading is older than the staleness horizon of the token it prices. The price is still returned: callers fold it into coverageQuality rather than dropping the holding and fabricating a chart gap.
sourceThe source of the asset’s own reading, the route’s first leg. null for identity.

Each leg is judged by the horizon of the token it prices, STALENESS_HORIZON_MS:

Asset classHorizon
crypto48 hours
fiat120 hours: a weekend plus a holiday
stock120 hours
custom, a person’s own assetnone: a typed price stands until the next
unknown48 hours

So a crypto price through a weekend-old exchange rate is not stale, and a typed price through a week-old exchange rate is.

PRICE_HUBS in price-hubs.ts is the only list: USD, USDT, EUR, in that order. PriceHubResolver resolves each to its canonical tokens row, the un-segmented one, and caches the ids for the process. Inside a transaction it neither reads nor fills that cache (SC-600), so each hub costs up to three lookups there.

A caller asks once for everything a request needs: the portfolio-value rollup asks for every holding at every day of the chunk a call computes, Returns for every flow and day of its window. series reads, for each token and instant, only the rows at the nearest stamp at or before that instant, and never a pair’s history. Once the hubs are known it takes a fixed number of statements however much is asked: one for the quote currencies, one for the token types, one for the readings.

fingerprint hashes the loaded readings, so a cache keyed on it moves exactly when a reading under it does.

Three granularities live in token_prices.granularity:

GranularitySourceUse
intradayLive syncs (CoinGecko paid, Finnhub, exchange tickers); a backfill’s quote for today, and a backfilled point the provider does not date to a day.Recent valuation, the dashboard headline.
dailyThe close of one UTC day: historical backfill (historical-price-backfill job); rows from the retired intraday downsampler (downsample-daily) remain.The chart, point-in-time historical valuation.
tx-exactCaptured 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.

A daily row for UTC day D carries D’s close and is stamped at D’s last millisecond (2026-09-20T23:59:59.999Z for 20 September), whichever provider answered. Each provider names the day its bar closes, so a bar stamped at its start, or at the midnight that ends it, still lands on its own day. History is read at the same instant: RollupPortfolioValueDailyUseCase values a past day at 23:59:59.999Z, so the day reads its own close and never a reading from the day after.

Today has no close. A backfill’s quote for the current UTC day is stored as an intraday reading at the instant it was fetched: it is a price now.

A close belongs to its source. Every provider’s close of a day shares one stamp, so a later answer from another provider does not overwrite it; the same source does, and so does any provider over a downsample-daily row, which only stands in for a close.

The backfill asks for a past day only while it is not covered. A day is covered by a daily row of any source, or by a reading at or after 23:00 UTC of that day, which stands in for the close. Today is covered by any reading since midnight UTC. A day older than seven days is covered by any reading: a weekend or holiday has no close at any provider, and asking for one every night would never stop.

token_prices.price is text. A price fetched from a provider, backfilled or entered by hand is written through PriceWriter; the demo and end-to-end seeders still write the table themselves. PriceWriter sends a price only when the text is a canonical positive decimal:

  • digits, optionally a point and more digits — at most 64 on each side;
  • optionally an exponent of at most three digits (9.9e-9, 1E3);
  • greater than zero once read.

A text that does not qualify is dropped and counted, never stored: '0', '-5', ' 1.5', '+1', 'NaN', 'Infinity', '0x1A', '1_000', '12abc'. The rest of its batch is still written. The column’s CHECK refuses the same texts, so a write that bypasses the writer fails with 23514 rather than storing one.

A backfill counts what the writer drops in two units. droppedBars is every bar it did not store. droppedDays is the days the run asked for that a provider answered only with such bars, separate from inserted and providerMissing: nothing was stored, so the day is not inserted, and the provider did answer, so it is not missing. A dropped day keeps a token off the unpriceableUntil cooldown exactly as a stored day does; a bar dropped on a day the run did not ask for does not. A manual price that would be dropped is refused with the positive-price error.

Every fiat rate comes from a named central bank’s published table, read through Frankfurter v2 by one client. Two banks are read:

  • The European Central Bank (euro foreign exchange reference rates). A pair whose two currencies are both in the ECB’s table, USD among them, is read from it: /v2/providers/ecb/rates?base=EUR.
  • The Bank of Russia (official exchange rates). A pair with a currency the ECB does not publish but the Bank of Russia does (RUB, KZT, AED, …) is read from the Bank of Russia’s table: /v2/providers/cbr/rates?base=USD.

Both sides of a pair Frankfurter is asked for always come from one bank. Two banks’ crosses of the same day differ by about a percent, so a pair priced half from each would be off by that much. So a pair of a currency only the ECB publishes (ILS, ISK, MXN, MYR, PHP) with one only the Bank of Russia publishes gets no rate from Frankfurter. The price graph, which reads stored rows, can still chain a Bank of Russia row with an ECB row through USD when no stored row prices the pair directly.

A currency in neither table is not priced by Frankfurter. The hourly pricing run sends fiat to Frankfurter alone, so it gets no current price, and the Google Sheets converter cannot convert it. The nightly history backfill still falls back to Yahoo Finance, asked for the pair itself (<FROM><TO>=X).

Each bank is asked in the base it publishes, never for the pair itself, and the pair is divided here: a bank’s own figure comes back only in that direction, and anything else is Frankfurter’s cross, rounded to about five digits. EUR into another ECB currency is the ECB fixing as sent, and RUB into USD is the Bank of Russia’s rate inverted. The stored price of X in B is rate(pivot to B) / rate(pivot to X), where the pivot is EUR for the ECB and USD for the Bank of Russia. The Bank of Russia’s other currencies are crosses through USD, so they keep about five significant digits.

The division is decimal, at 28 significant digits, and the stored text is its result. A rate counts only when it is a number above zero. A currency missing from the answer gives no price, and nothing is written. An answer in a base other than the one asked is refused whole.

A day or a range names the bank the same way. A day with no fixing (a weekend or a holiday) answers with the bank’s last fixing before it, and the price is stored as the close of the day that fixing names, not the day asked. A range starts with the last fixing at or before its first day, and that row keeps its own day.

A rate read from the ECB is stored with the source frankfurter (frankfurter_historical for a day or a range); one read from the Bank of Russia with frankfurter-cbr (frankfurter-cbr_historical), and the app shows it as the Bank of Russia.

The client keeps each bank’s latest table for 60 minutes in each process, so a table costs one request an hour whatever the number of currencies. It does not keep a failed request, or an answer with no usable rate, and it asks for a day or a range every time. Its requests share one limiter namespace, frankfurter, at 10 a second, and one 8-second bound covers both the wait for a slot and the request. A row written from a kept table is still stamped at the instant Scani asked.

A current quote is stored at the instant Scani asked for it, as an intraday row, whichever provider answered. A provider’s own date is not used: Frankfurter dates its latest rate by its bank’s fixing day (the ECB’s or the Bank of Russia’s) at 00:00 UTC, and a row stamped that way was overwritten in place by every later ask of the same day.

So the age of a current price is the time since the last successful fetch, not the age of the provider’s data, and the staleness caps measure that. Two other readers change with it:

  • The Money tab’s rates. Each is PriceReader’s answer from stored rows, dated by the reading it binds to. It picks up a new fixing within the hour, and its date is the hour Scani last asked, not the ECB day: on a Sunday, Friday’s fixing reads as this hour’s.
  • Reuse for a token Frankfurter prices. The import warm-up and the refresh button leave a token unasked when its row against USD is under an hour old. For such a token that now happens. Before, its row sat at the ECB day’s midnight and never fell inside the hour, so each of them asked Frankfurter every time.

The hourly pricing job asks the providers on every run and reads no stored row first, so each pair it prices gains one row an hour. It prices, each against USD:

  • every held token a provider prices;
  • every currency in use: base, payment and vault currencies, and the currency a held token’s manual price is quoted in;
  • the routing hubs and the FX baseline (EUR, GBP, JPY, RUB, CHF, CAD, AUD, USDT).

A token priced by hand (private-company, other) is not sent to a provider: its last manual price stands.

The import warm-up and the refresh button read a stored row first, and ask a provider, against USD, only for a token with no row against USD under an hour old and no manual price in any base. A vault asks no provider: it reads PriceReader.

The nightly historical-price-backfill also writes FX history: currencies in use start at their users’ earliest ledger or observation day within the lookback, and unused baseline currencies cover the last seven days. USD is the base, so it needs no row. The backfill fills empty UTC days and leaves every day that already holds a price unchanged. An empty history response for a currency in use or a baseline member never gives it an unpriceable cooldown.

currency-rate-refresh asks the provider registry for both currencies against USD (only the other currency when one is USD); PriceReader then derives the requested pair through USD.

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.