Concepts
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. An oracle that goes dark breaks its consumers. An oracle that publishes a confident wrong number liquidates them. 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 move therefore publishes when it arrives. 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. Time arrives as an argument. A recorded sequence of observations therefore replays to the same prices, bit for bit, 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. On a thin or one-sided book the mid is a fiction, and the microprice degrades more gracefully. The rules for a book quote are:
- A crossed book (bid ≥ ask) is dropped, never repaired.
- 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 widerOne large order at the top of the book can lean a microprice. It cannot lean an impact price. The depth sets the level, and the top of the book sets the timing. 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. It 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 |
| 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, on a second connection |
sequence_num on every message |
| Binance | @depth@100ms diffs on a REST snapshot |
Each diff follows the last update id |
An OKX swap book is in contracts. Its source must set contract_size, or the node refuses the config.
Staleness filter
The node drops a venue whose newest usable observation is too old. Two ages count:
- Confirmed age: the time since the venue's connection last confirmed the value. Each frame on the connection confirms each book, mark, index and funding value that the connection has delivered. A data message, a heartbeat and a pong all count. After 1 s with no frame, the node sends a WebSocket ping, and the venue's pong confirms the connection. 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 market sends no new value, but the venue keeps sending frames. A quiet value thus 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: Coinbase's ticker moves only on a trade, and Bybit sends a perpetual's fields 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. Venue clocks skew, jump and stop during incidents. After a reconnect, the node drops the old state of that venue at once. It never carries a book across a gap.
USDT to USD normalisation
Binance, Bybit and OKX quote in USDT. A /USD feed converts each of their sources through the peg feed CRYPTO.USDT/USD, which the node prices from Coinbase and Kraken:
usd_price = usdt_price × peg_price
rel_conf² = (conf / price)² + (conf_peg / price_peg)²The node converts before outlier rejection. If it did not, 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. The node therefore stops using its last value at once. Otherwise a dead peg would convert the dependent feeds forever. It never publishes a USDT number under a /USD symbol: without a peg, only the native USD venues count.
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). Unlike a standard deviation, the very outlier that it must catch does not drag it.
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 is necessary. When all venues agree within a hair, σ̂ goes to zero, and a pure MAD test would reject good quotes over rounding noise. The test is unweighted on purpose, 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. A wide honest price is better than a narrow price from one survivor.
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, no majority says which one is wrong. 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. Every quote that reaches the median is confirmed within the staleness limit, so it is current. A weight that fell with age rose and fell with the 1 s probe, and with two sources it made the price jump between them.
Then the node caps each weight at 3 times the median weight. Spread and depth are inputs that a manipulating venue controls. A weighted median gives no protection against weight itself: a sample with more than half the total weight is the answer. The cap makes an implausibly tight book buy influence sub-linearly, not buy 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 weights must not pick the winner. The price is the plain median. With two sources, the price is the quote nearer to the last published price. One moved venue therefore 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, which is when both are under stress. The cap does not drop a trigger. The node evaluates the feed again when the cap expires. A change of flags, such as a halt, 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, the same in every node, and not a setting. A feed is slow by its kind: FUNDING_8H and OPEN_INTEREST feeds are slow, and 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 failure that nothing else catches: every venue agrees, and every venue is wrong. Examples are 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 Run a node.
A slow feed (funding, open interest) also has a 90 s staleness limit and no sanity band. The symbol's kind 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 or 4 hours on Binance by contract. The message does not say which interval.
Nothing converts between the intervals. The interval is not in the WebSocket message, and the metadata fetch that would supply it does not exist yet.
The consequence: a FUNDING_8H feed with sources on different intervals takes a median across numbers that do not compare. A 1-hour rate and an 8-hour rate differ by 8 times. Nothing halts or degrades. The feed publishes a confident wrong number, which is the failure that the rest of the design works to prevent.
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 metadata fetch exists.