Reference
Aggregation
How a node turns the quotes of several venues into one price, one confidence interval and status flags, and when it publishes them.
Each node aggregates on its own. For each feed, it turns the latest quotes of the configured venues into one price, one conf and a set of status flags. Then it decides whether to publish.
The governing bias
Degrade loudly, halt rarely, never silently narrow. A wider conf is the pressure-release valve. A node halts a feed in two cases only, because no honest price exists to widen around:
- too few live sources;
- a price outside the sanity band.
The pipeline
observations ──▶ per-venue quote (microprice, staleness, crossed books)
──▶ outlier rejection (MAD, unweighted, never below quorum)
──▶ weight cap (no source may buy the median)
──▶ weighted median (plain median when venues disagree and none can go)
──▶ confidence (disagreement ∨ spread ∨ floor)
──▶ circuit breakers (jump corroboration, sanity band)
──▶ publication policy (rate cap, then deviation ∨ heartbeat)The node evaluates a feed when one of its inputs changes: a new observation, a disconnect, or a new reading of its peg. A timer also evaluates every feed every 25 ms (tick_interval_ms). The timer catches heartbeats and stale venues.
The aggregator has no clock and no I/O. A recorded sequence of observations therefore replays to the same prices on any machine.
Source price per venue
For each venue of a feed, the node derives one number. The source config lists the observation kinds in order of preference. For a spot feed, the default is the top of the book, then the last trade.
mid = (bid + ask) / 2
micro = (bid × ask_qty + ask × bid_qty) / (bid_qty + ask_qty)
source_price = micro if both sides are present, else the last tradeThe microprice leans toward the side with pressure, so it degrades better than the mid on a thin book. The rules for a book quote are:
- The node drops a crossed book (bid ≥ ask). It never repairs it.
- A price that is zero or negative is invalid.
- A fallback to the last trade does not degrade the feed by itself. The missing spread term makes
confwider instead.
Impact price from the book
A feed can set impact_notional. The node then prices each venue at the average fill for that notional, from the local book of the venue:
impact_bid, impact_ask = average fill prices for impact_notional, sell side and buy side
source_price = (impact_bid + impact_ask) / 2
+ (mid_now − mid_at_snapshot) when the top of book is newer than the book
half_spread = (impact_ask − impact_bid) / 2 a thin book makes conf widerThe depth sets the level, and the top of the book sets the timing. One large order at the top of the book cannot lean an impact price. The rules are:
- A stale book falls back to the microprice.
- A book with too few levels for the notional gives no impact price.
- The impact price replaces only a top-of-book price. A mark or index from a venue stays as the venue computes it.
The node keeps each venue book from a snapshot and the changes after it. Where the venue numbers its changes or sends a checksum, the node proves that each change follows the last one. A failed proof drops the connection, and the reconnect brings a fresh snapshot.
| Venue | Stream | Proof |
|---|---|---|
| Kraken | book, depth 100 |
CRC32 of the top 10 levels |
| OKX | books, 400 levels |
seqId and prevSeqId, and the CRC32 of the top 25 levels when the checksum is not 0 |
| Deribit | book.<instrument>.100ms |
change_id and prev_change_id |
| Bybit | orderbook.50 |
Update id u rises by one; u = 1 or a snapshot resets |
| Coinbase | Advanced Trade level2, 25 products a connection |
sequence_num on every message |
| Binance | @depth@100ms diffs on a REST snapshot |
Each diff follows the last update id |
| Hyperliquid | l2Book, 20 levels |
Not necessary: each message is the whole book |
| Bitfinex | book, 25 levels |
CRC32 of all 25 levels, every few seconds |
| MEXC | spot@public.limit.depth.v3.api.pb on spot, sub.depth.full on perpetuals; 20 levels |
Not necessary: each message is the whole book |
| KuCoin | /spotMarket/level2Depth50 and /contractMarket/level2Depth50, 50 levels |
Not necessary: each message is the whole book |
| Bitstamp | order_book, 100 levels |
Not necessary: each message is the whole book |
| Bitget | books, 1,000 levels |
Each change names the seq of the last one as pseq |
| Gate | spot.order_book and futures.order_book, 50 levels |
Not necessary: each message is the whole book |
| Lighter | order_book, the whole book |
Each change names the nonce of the last one as begin_nonce |
| dYdX | v4_orderbook from the indexer, the whole book |
None: the changes carry no number. A crossed book ends the connection |
OKX swaps and the perpetuals of KuCoin, MEXC and Gate count in contracts. Bitget's coin-margined perpetuals count in USD, which the node divides by the price, as for Deribit. Each such source must set contract_size, or the node refuses the config: the ctVal of an OKX swap, the multiplier of a KuCoin contract, the contractSize of a MEXC contract, or the quanto_multiplier of a Gate contract. The node then reports its sizes in coins.
Coinbase sends its ticker only on a trade. The node therefore takes the Coinbase top of book from the level2 book and uses the ticker only for trades.
Staleness filter
The node drops a venue whose newest usable observation is too old. Two ages count:
- Confirmed age: the time since the connection of the venue last confirmed the value. Each frame on the connection (a data message, a heartbeat or a pong) confirms each book, mark, index and funding value that the connection has delivered. After 1 s with no frame, the node sends a WebSocket ping. A trade is an event, not a state, so only its own receipt counts.
- Unchanged age: the time since the value itself arrived.
| Feed type | Confirmed age | Unchanged age |
|---|---|---|
| Spot, mark, index, mid | 2000 ms | 5 min |
| Slow feeds (funding, open interest) | 90 s | 5 min |
A quiet value on a live connection stays current. A silent connection confirms nothing, and its values go stale after 2 s. The unchanged limit catches a connection that stays up while the venue stops the stream of one instrument. It is long because a quiet value can rest: Bybit sends the fields of a perpetual only when they change.
A feed can set its own confirmed-age limit with max_age_ms.
The node measures both ages with its own receipt clock, never with the venue timestamp. After a reconnect, the node drops the old state of that venue at once.
USDT to USD normalisation
Binance, Bybit, OKX, KuCoin, MEXC, Bitget and Gate quote in USDT, and the feeds use the USDT books of Bitfinex. A /USD feed converts each of these sources through the peg feed CRYPTO.USDT/USD, which the node prices from Coinbase, Kraken and Bitstamp. Hyperliquid, Lighter and dYdX quote in USDC, and the feeds take it at par. See Feeds for the peg symbol.
usd_price = usdt_price × peg_price
rel_conf² = (conf / price)² + (conf_peg / price_peg)²The node converts before outlier rejection. Otherwise a depeg would look like every USDT venue disagreeing with the USD venues.
| Peg state | Effect on each dependent feed |
|---|---|
Live, conf up to 50 bp |
Converted. SYNTHETIC_QUOTE set. conf widened in quadrature. |
Live, conf above 50 bp (max_peg_conf_bps) |
Converted. SYNTHETIC_QUOTE and DEGRADED set. |
| Halted or missing | The converted sources drop out, with status peg_unavailable. The native USD sources price the feed, and DEGRADED is set. With fewer native sources than min_sources, the feed halts. |
A halted peg has no current reading, so the node stops using its last value at once. Without a peg, only the native USD venues count. The node never publishes a USDT number under a /USD symbol.
A peg feed cannot itself need conversion, and a feed cannot be its own peg. The node refuses such a config.
Outlier rejection
The node uses the median absolute deviation (MAD):
m = median(source_prices)
mad = median(|xᵢ − m|)
σ̂ = 1.4826 × mad
drop xᵢ where |xᵢ − m| > max(k × σ̂, floor_bps × m) k = 4, floor = 10 bpThe floor prevents a rejection over rounding noise when all venues agree and σ̂ goes to zero. The test is unweighted, because a venue controls its own weight.
The node never drops below min_sources. If rejection would go below it, the node keeps every quote and sets DEGRADED.
Two sources. Two quotes sit at the same distance from their midpoint, so the test can never reject one of them. If they differ by more than the 10 bp floor, the node keeps both and sets DEGRADED.
Weighted aggregate
Each surviving source gets a weight:
wᵢ = w_venue static weight from the config, 1 by default
× w_depth sqrt(depth within 1 bp / 100000), capped at 2; only when every source has a book
× w_spread 1 / (1 + spread_bps)Age has no weight, because every quote that reaches the median is current within the staleness limit.
The node then caps each weight at 3 times the median weight. A venue controls its own spread and depth, and a sample with more than half the total weight is the weighted median. The cap stops a tight book from buying the price.
price = weighted_median(xᵢ, wᵢ)The node uses a weighted median, not a weighted mean. One venue with an absurd value cannot move a median, whatever its weight.
When rejection was blocked. If the node kept every quote because of the quorum, the price is the plain median. With two sources, the price is the quote nearer to the last published price. One moved venue makes conf wider but cannot move the price. conf widens to cover the farthest quote that the node kept.
Confidence
conf = max(
max(weighted_mad, unweighted_mad) × 1.4826, how much the venues disagree
weighted mean of the half-spreads, nobody trades inside the book
price × 1 bp the floor
)The unweighted MAD is necessary. A source with more than half the weight makes the weighted MAD zero, however far away the other sources are.
Publish policy
The node publishes when one of the triggers fires and the rate cap allows it:
publish if:
|price − last_published| / last_published ≥ deviation_bps
OR now − last_publish ≥ heartbeat
OR the flags changed
AND now − last_publish ≥ min_interval| Setting | Default | Slow feeds |
|---|---|---|
deviation_bps |
1 bp | 1 bp |
| heartbeat (fixed) | 1 s | 10 s |
min_interval_ms (rate cap) |
50 ms | 1 s |
The rate cap comes first. It bounds the signing cost and the subscriber bandwidth during volatility. The cap does not drop a trigger: a change of flags publishes at the next allowed instant.
deviation_bps defaults to the conf floor. A smaller move is inside the uncertainty of the oracle. Each feed can set deviation_bps and min_interval_ms.
The heartbeat is a protocol rule, not a setting. FUNDING_8H and OPEN_INTEREST feeds are slow; every other feed is not. Consumers derive their staleness limit from the heartbeat. The Rust SDK allows 2 s for a price feed and 25 s for a slow feed.
observation_time_ns in the update is the newest receipt time among the contributing observations.
Circuit breakers
| Condition | Action |
|---|---|
Live sources below min_sources |
HALTED. The node publishes the last good price at heartbeat cadence. |
Outlier rejection would go below min_sources |
DEGRADED, conf widened. |
| Move of 300 bp or more from the last published price, and fewer than 2 venues moved that far | DEGRADED, conf widened to cover the jump. The price is not suppressed. |
| Peg feed halted or missing | DEGRADED on each feed that needs conversion, priced from its native sources; HALTED if they are fewer than min_sources. |
Peg conf above max_peg_conf_bps (50 bp) |
DEGRADED on each feed that needs conversion. |
| Price outside 0.5× to 2× of the reference EMA (half-life 5 minutes) | HALTED. |
The sanity band catches the case where every venue agrees and every venue is wrong, such as a bad instrument mapping or a redenomination. Slow feeds turn the sanity band off, because a funding rate crosses zero routinely. Halted prices do not update the reference EMA.
Status flags
Each signed update carries a 16-bit flags field.
| Bit | Flag | Meaning to a consumer |
|---|---|---|
| 0 | HALTED |
Do not use the price for settlement or liquidation. The update carries the last good price so that you can display something. conf is set to the size of the price itself. |
| 1 | DEGRADED |
The price is usable, but the inputs are impaired and conf is wider. Act on conf. |
| 2 | SYNTHETIC_QUOTE |
At least one source went through a peg conversion. The price carries the uncertainty of the peg. |
| 3–15 | reserved | Always 0. |
A decoder rejects an update with a reserved bit set. It does not mask the bit. An old client that cannot read a new warning must not conclude that no warning exists.
The SDK combines the flags of the nodes with its own checks into one status: ok, degraded or halted. See Trust model.
Per-feed settings
An operator sets these values for each feed in the node config:
| Setting | Default | Effect |
|---|---|---|
min_sources |
2, or 1 for slow feeds | Fewest live sources for a usable price. |
deviation_bps |
1 |
Move that triggers a publication. |
min_interval_ms |
50, or 1000 for slow | Rate cap. |
max_age_ms |
2000, or 90000 for slow | Confirmed-age limit for each source. A quiet book on a live connection stays current, so the default suits a stablecoin pair too. |
impact_notional |
none | Price each venue at the average fill for this notional, in quote units. |
peg |
none | The peg feed for converted sources, such as CRYPTO.USDT/USD. |
max_peg_conf_bps |
50 |
Peg conf above which the feed degrades. |
Each source also has venue, market, symbol, and optional kinds, weight, converted and contract_size. See Feeds.
A slow feed (funding, open interest) also has a 90 s staleness limit and no sanity band. The kind of the symbol decides this, so the config has no setting for it.
Funding is not normalised
Caution
A FUNDING_8H feed promises a rate normalised to 8 hours. Only Deribit publishes such a rate. Every other venue publishes a fraction over its own interval: hourly on Kraken Futures, 8, 4 or 1 hour on Binance by contract.
Nothing converts between the intervals. The funding stream states the interval of each venue, but the aggregator does not use it yet. A FUNDING_8H feed with sources on different intervals therefore takes a median across numbers that do not compare. A 1-hour rate and an 8-hour rate differ by 8 times, and nothing halts or degrades.
Safe configurations today:
- Deribit alone, which publishes a real 8-hour rate;
- a single venue;
- several venues on a contract where you confirmed the same interval. BTC and ETH majors are 8 hours on Binance, Bybit and OKX.
Treat funding from several venues as unsupported until the aggregator converts by interval. To compare venues now, read the funding stream and divide each rate by its interval.