Prices here are measured from chain events, not sourced from an aggregator.
That is the point: you can trace any figure back to the log that produced it.
Per architecture
Two kinds of source. For Uniswap the price is in the event and a bar is
one aggregate over the log table. For Curve and Balancer the price is a
state the events only change, so the platform replays the pool’s own
events in order, quotes the invariant after each one, and folds the quotes
into bars. Every bar is token0 per token1, where token0 and token1 are the
pool’s first two tokens; a pool with more (a Curve 3-pool) is charted on
that pair and is not listed for its other pairs, whose series it does not
have.
- Constant-product (Uniswap V2 and forks): the price is the reserve ratio
after eachSyncevent. Open, high, low and close within a bucket come from
the sequence of ratios in that bucket. - Concentrated liquidity (Uniswap V3): the price comes from
sqrtPriceX96on eachSwap, squared and scaled. - StableSwap (Curve plain pools): the pool’s balances and
Aare replayed
fromTokenExchange,AddLiquidity,RemoveLiquidity,
RemoveLiquidityImbalance,RemoveLiquidityOne,RampA,StopRampAand
NewFee, with the contract’s own integer arithmetic (the admin’s share of
each fee leaves the balances; a one-coin withdrawal’s fee is recomputed
through_calc_withdraw_one_coin;Ais interpolated through a ramp on
the contract’s scale). The quote after each event is the contract’s
get_dyfor one whole unit of coin 1 into coin 0, so it includes the
pool’s swap fee, exactly as the contract quotes it. Two checks run inside
the replay: a swap’stokens_boughtand a withdrawal’scoin_amountare
recomputed from the state, and a disagreement is recorded as a divergence
on the pool’s checkpoint (prices:replay:status) rather than hidden.
Only plain pools: no metapools, lending pools, CryptoSwap pools or pools
holding raw ETH, which the registration refuses. - Weighted (Balancer V2): balances are replayed from the Vault’s
Swap,
PoolBalanceChangedandPoolBalanceManagedevents for the pool id (a swap
moves exactly its amounts; a join or exit moves each token by its delta
less the protocol fee the Vault withheld). The quote is the spot price
(balance0 / weight0) / (balance1 / weight1)before the swap fee, with the
pool’s registered weights. Pools whose weights travel (liquidity
bootstrapping and managed pools) are refused at registration. - Batch auction (CoW Protocol): every
Tradea settlement contract
emits is a realised price, the batch’s uniform clearing price applied to
one order:(sellAmount − feeAmount) / buyAmount, oriented token0 per
token1 by address. Bars are one aggregate over those trades per
(settlement, pair, hour), stored under the key
<SETTLEMENT>_<TOKEN0>_<TOKEN1>. They are computed but not listed in
the finder: a batch auction has no pool per pair to list, and the
finder’s pages are keyed by pool address. The source therefore stays
marked unavailable in the registry until the finder can list a pair-keyed
venue.
Registered pools
A Curve or Balancer pool’s events carry every balance change but not what
the contract was deployed with (coin order and decimals, A and its scale,
the fee parameters; the weights), and a price computed with a guessed
parameter is a plausible wrong number. So those two sources chart
registered pools only: bin/rails prices:curve:register POOL=0x… and
prices:balancer:register POOL_ID=0x… read the parameters from the contract
(and the deployment block from the warehouse) into db/data/curve_pools.yml
and db/data/balancer_pools.yml, then markets:seed_paper lists the pool
and the 15-minute refresh builds its bars from the pool’s first block in
bounded strides (prices:replay:backfill ARCH=… does it in one go). A Curve
pool whose fee changed after deployment is refused unless INITIAL_FEE and
INITIAL_ADMIN_FEE are supplied from the deployment transaction, because no
event says what they were before. Nothing in either file is typed in from
memory.
Validation against the chain
Each architecture has a harness; the two that read historical contract
state (through the public RPC endpoints that keep archive state) are the
owner’s to run.
bin/rails prices:validate:curve(POOLS=0x…,…,DATES=YYYY-MM-DD,…;
defaults: the first three registered pools, 1, 91 and 181 days ago) stands
each pool at each day’s last block from its events and readsget_dy,
A()and everybalances(i)from the node at the same block. Balances and
Amust match exactly and are named beside the line when they do not;
get_dyis held to a relative tolerance of 1e-5, which is where the
one unobservable would show: a 2020 pool’sdonate_admin_feesmoves admin
fees intobalanceswithout an event. A disagreement beyond that is a
mismatch and the task aborts.bin/rails prices:validate:balancer(same options) compares the replayed
balances withgetPoolTokensexactly, and the amount out our state and
weights give for a probe of one millionth of the token1 balance, through
the pool’s out-given-in formula with its fee, withqueryBatchSwapat the
same block, to a relative tolerance of 1e-6 (the fixed-pointpow’s
own error bound).bin/rails prices:validate:cow DATE=YYYY-MM-DDneeds no node: it reads the
day’s trades, folds them in Ruby, and compares every hour of the busiest
pairs (PAIRS=50) with the bars the aggregate wrote, field by field, and
checks that the hour’s volume-weighted average price lies between the
bar’s low and high.
Bars
Bars are precomputed hourly per tracked pool and refreshed every 15 minutes:
the Uniswap and CoW aggregates from a block watermark, the replayed pools
from each pool’s checkpoint (its state at a block and the hour in progress),
a bounded stride per run so a newly registered pool catches up over a few
runs. New Uniswap pools get their full history on first sight.
Bars are stored unscaled. Token decimals are applied at read time. If you
query the warehouse directly, apply decimals yourself; if you use the API, the
chart, or the CSV, it is already done: do not do it twice.
Charts prefer precomputed bars and fall back to a live event scan for a
Uniswap pool that has none yet. A replayed pool has no price outside its
bars, so it shows an empty chart until its replay has reached the window.
When the decimals are unknown
A price is a ratio of two token amounts, and an amount cannot be read without
the token’s decimals: a 6-decimal token read as an 18-decimal one is wrong by
a factor of 10^12 and looks exactly like a real price. So a market whose
token decimals are not on record gets no series at all until they are:
- the chart shows a “decimals unknown” state and names the token, in full;
- the CSV download is refused with the same message;
- the API answers
409 decimals_unknown; - citations, classroom snapshots, watchlist summaries and price alerts wait.
Each of those requests the metadata as it refuses, so the state resolves
itself within minutes for any token the metadata provider knows. It used to
default to 18 silently, which is the one thing this page must never do.
The consolidated series
Several pools trade the same pair, and each prices it a little differently.
The finder used to answer with the busiest pool’s daily close, silently. It now
answers with the pair’s consolidated hourly series: for each hour, every
live venue’s close weighted by that venue’s volume in the hour. The venues are
listed beneath it with their own latest closes, so the spread is visible
rather than averaged away.
The consolidated bars live in the same table as the per-pool bars, under the
architecture consolidated and a synthetic pool key built from the two token
addresses in address order (PAIR_<token0>_<token1>). They are recomputed
every 15 minutes, five minutes behind the per-pool refresh, over the last two
days. Highs and lows are the highest high and the lowest low across venues,
not an average of extremes; a venue whose token order differs from the pair’s
is inverted before it is folded in. A pair with only one live venue is not
consolidated: that venue’s series is already the answer.
When nothing has been consolidated for a pair yet, the answer card falls back
to the busiest venue’s daily close and says so.
Comparing pools on one chart
The date axis of a chart with overlays is the union of every plotted series’
dates. A day on which the main pool did not trade but a comparison pool did
shows the comparison bar and a gap in the main line; nothing is dropped and
nothing is carried forward.
Volume
Volume is summed from trade amounts in the bucket, in token0 units (Swap
amounts for Uniswap and Balancer, TokenExchange amounts for Curve, the
net sell or buy side for CoW). It is not converted to USD, because that would
need a reference price we would then be asserting rather than measuring.
When a value is missing
A gap in a series means no trade in that bucket. It is not carried forward and
it is not interpolated. An empty chart means no data, not a broken page.