Esc

    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

    text
    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.

    text
    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 trade

    The 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 conf wider 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:

    text
    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 wider

    One 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:

    text
    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.

    text
    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 bp

    The 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:

    text
    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.

    text
    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

    text
    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:

    text
    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.