Pricing & the price graph
Summary
Section titled “Summary”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.
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) that have edges from many other tokens.
AAPL ──$── USD ─────────── EUR ──€── BTC │ │ USDT GBPHow a price is chosen
Section titled “How a price is chosen”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:
- Identity. The asset is the base: price 1, never stale.
- Direct. A reading
asset → base. - Inverse. A reading
base → asset, as1 / price. - Through each hub, in
PRICE_HUBSorder: asset to hub, then hub to base, each leg in whichever direction has the later reading. - 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 unpricedpriceAt throws a RangeError for a (token, instant) the series was
not asked for, so a missing ask fails loudly rather than reading as
unpriced.
What the answer tells you
Section titled “What the answer tells you”Staleness contract
Section titled “Staleness contract”Each leg is judged by the horizon of the token it prices,
STALENESS_HORIZON_MS:
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.
One load per request
Section titled “One load per request”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.
Granularities
Section titled “Granularities”Three granularities live in token_prices.granularity:
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 day’s price is its close
Section titled “A day’s price is its close”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.
What a stored price is
Section titled “What a stored price is”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.
How an exchange rate is asked
Section titled “How an exchange rate is asked”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.
When a current price is stamped
Section titled “When a current price is stamped”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.
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.