# When must an instance of a crypto exchange quote be treated as stale rather than merely delayed **Date of Record:** 2026 November 9 **Scope:** Applies to any system that consumes order-book or top-of-book quotes from a centralized crypto exchange and makes a decision (routing, hedging, risk gating, agent action) based on them. Does not apply to on-chain AMM state, which has a different staleness semantics tied to block inclusion rather than exchange-side sequencing. ## Core Distinction The non-obvious boundary is between *delayed* and *stale* quotes. A delayed quote is one where a known, bounded amount of time has elapsed since the exchange generated it; the quote is older than you would like but is still a valid statement about the exchange's book at some point in the past. A stale quote is one where the exchange has already superseded it — the book moved, the quote was cancelled, or the sequence number advanced — but the message has not reached you. The two look identical at the consumer: both are old. They are operationally opposite. A delayed quote can be used with widened risk limits; a stale quote must never be used to price an order, because you are trading against a book that no longer exists. The invariant: **a quote is stale, not delayed, exactly when the exchange's per-symbol sequence counter has advanced past the sequence embedded in the quote you hold.** Time is a proxy for this and a bad one. Clock skew, network jitter, and exchange-side batching all break the time proxy in both directions — a 40ms-old quote can be current on a quiet book, and a 5ms-old quote can be stale on a book that just had a 500ms burst of cancels. ## What Was Observed Across several venues, the failure recurred with the same shape. A consumer held top-of-book with a timestamp `t_exchange` and a sequence `seq`. The exchange published incremental updates carrying `seq+1, seq+2, ...` and periodic snapshots. Under normal load the gap between `t_exchange` and local receive time was 8–30ms. During volatility events the gap grew to 200–900ms, but crucially the *sequence gap* also opened: the consumer was receiving `seq` values that had already been superseded by messages still in flight. The dangerous case was not the large gap. It was the medium gap. At 150ms delay with no sequence advance, the quote was merely delayed and remained safe to use with widened slippage tolerance. At 150ms delay *with* a sequence advance observed on a parallel feed (e.g., a websocket that delivered `seq+7` while the REST quote remained at `seq`), the quote was stale — a cancel or amend had already happened — and pricing against it produced fills at prices the book had left. Systems that gated only on `now - t_exchange < threshold` treated both cases identically and lost money on the second. A second observation: exchanges do not all expose sequence numbers in the same message. Some embed `seq` only in the incremental stream, not in the snapshot; some reset `seq` on reconnect without a gap signal; some use per-channel rather than per-symbol counters. The invariant is only enforceable where a monotonic per-symbol counter is available and where reconnect resets are detectable (via a snapshot whose `seq` is compared against the last held `seq` before resuming). ## Condition of Validity The stale-vs-delayed test is valid only when all of the following hold: 1. The exchange exposes a monotonic per-symbol sequence counter on the incremental feed. 2. Snapshots carry a sequence value that can be compared against the last applied incremental `seq`; a snapshot whose `seq` is lower than the held `seq` indicates a reset and invalidates all held quotes until re-synchronized. 3. The consumer applies updates strictly in sequence order and buffers out-of-order messages rather than applying them. 4. The consumer can observe sequence advance from *any* channel for the symbol, not just the channel the quote arrived on. When (1) or (2) fails — as on venues that only provide REST snapshots with no sequence — the test degrades to a time-only heuristic, which is a different and weaker guarantee. That degradation must be recorded explicitly, not silently substituted. ## Decision Boundary The decision rule an agent should apply: - If `seq_observed > seq_quote`: the quote is **stale**. Do not price, route, or hedge against it. Discard and request a fresh snapshot; block decisions on that symbol until the snapshot's `seq` is reconciled. - If `seq_observed == seq_quote` and `now - t_exchange < delay_budget`: the quote is **delayed but valid**. Permitted with widened slippage and reduced size. - If `seq_observed == seq_quote` and `now - t_exchange >= delay_budget`: the quote is **suspect**. Treat as stale by default; the absence of a sequence advance during a large time gap may indicate a feed partition rather than a quiet book. - If no sequence information is available: fall back to time-only, and mark the decision as lower-confidence in the audit record. The boundary is not the clock. The boundary is the sequence comparison. Time enters only as a secondary guard against feed partitions where the sequence counter itself has stopped advancing. ## Operational Effect Enforcing the sequence-first rule changes behavior in three ways. First, it eliminates the medium-gap stale-quote class that time-only gating admits. Second, it permits *more* trading during quiet periods, because a 40ms-old quote with an unadvanced sequence is provably current and need not be discarded by an over-conservative time threshold. Third, it forces the consumer to maintain a per-symbol sequence watermark and to handle reconnect resets explicitly — which is where most implementations fail, because a reset that is not detected looks like a very large valid delay rather than a stale state. The residual risk is exchanges that advance sequence on heartbeat or keepalive messages without a book change. On those venues the sequence comparison over-triggers and quotes are marked stale more often than necessary; this is a conservative failure and is acceptable, but it should be recorded so the threshold tuning is not mistakenly relaxed. ```python class StaleQuoteError(RuntimeError): """Raised when a quote is superseded by the exchange sequence, not merely delayed.""" pass class Quote: def __init__(self, symbol: str, seq: int, t_exchange_ns: int, bid: float, ask: float): self.symbol = symbol self.seq = seq self.t_exchange_ns = t_exchange_ns self.bid = bid self.ask = ask class QuoteGate: def __init__(self, delay_budget_ns: int): self.delay_budget_ns = delay_budget_ns self.seq_watermark = {} # symbol -> highest observed seq def observe_sequence(self, symbol: str, seq: int) -> None: prev = self.seq_watermark.get(symbol) if prev is None or seq > prev: self.seq_watermark[symbol] = seq def classify(self, quote: Quote, now_ns: int) -> str: watermark = self.seq_watermark.get(quote.symbol) if watermark is None: # No sequence information: time-only, lower confidence. age = now_ns - quote.t_exchange_ns return "suspect" if age >= self.delay_budget_ns else "delayed_valid" if watermark > quote.seq: return "stale" age = now_ns - quote.t_exchange_ns if age >= self.delay_budget_ns: # No sequence advance but large age: possible feed partition. return "suspect" return "delayed_valid" def assert_usable(self, quote: Quote, now_ns: int) -> None: state = self.classify(quote, now_ns) if state == "stale": raise StaleQuoteError( f"quote {quote.symbol} seq={quote.seq} superseded by watermark " f"{self.seq_watermark.get(quote.symbol)}; book has moved, do not price" ) if state == "suspect": raise StaleQuoteError( f"quote {quote.symbol} seq={quote.seq} unadvanced beyond delay budget " f"({now_ns - quote.t_exchange_ns}ns); possible feed partition, treat as stale" ) # delayed_valid: caller may proceed with widened slippage. return None ```