sifting/io
Developer Tutorials
6 min readSiftingIO Team

Bid ask spread: how to read a quote and flag a wide or stale one in code

How to read bid and ask from a quote snapshot, compute the bid ask spread in basis points, and flag wide or stale quotes in Python before users see them.

Bid ask spread: how to read a quote and flag a wide or stale one in code

The bid ask spread is the fastest health check you can run on a market data quote. Pull a snapshot for EURUSD or BTCUSD and you get two prices, not one: the bid, the best price buyers are offering right now, and the ask, the best price sellers will accept. The gap between them tells you how liquid the instrument is at this moment and how much to trust the number. A tight spread on a major pair means an active market and a price close to reality. A spread ten times wider than usual, or a timestamp from four minutes ago, means something is wrong, and your code should catch it before the quote reaches a user.

This post shows how to read bid and ask from a snapshot, turn them into an absolute and a relative spread, and write a check that flags a wide or stale quote automatically. The examples use Python against the SiftingIO REST API, but the logic ports to any language in a dozen lines.

What the bid ask spread tells you#

The absolute spread is just ask - bid. On its own it's almost useless for comparison, because it lives in the units of the instrument. A spread of 0.0002 is meaningful on a currency pair quoted near 1.17 and invisible on a crypto pair quoted in the tens of thousands. What you want for thresholds is the relative spread, normalized by the mid price:

mid        = (bid + ask) / 2
spread     = ask - bid
spread_bps = spread / mid * 10000

Basis points (one bps is 0.01%) put every instrument on the same scale. A liquid FX major and a thin exotic pair can now share one alerting pipeline with different thresholds instead of different math.

The spread widens for reasons you care about: the order book is thin, the market is outside its active hours, a venue is having trouble, or the feed behind the quote has gone quiet while the real market moved. In all of those cases, displaying the price without a caveat, or feeding it into a model, produces wrong results. There's also one hard failure mode worth checking explicitly: a crossed quote, where bid is greater than or equal to ask. A real market can't stay crossed, so in a snapshot it almost always means a broken or badly merged feed. Treat it as invalid data, not as a curiosity.

Pull a quote and compute the spread#

SiftingIO exposes the current best bid and ask through the live snapshot family. One endpoint shape covers every asset class; only the venue segment changes:

curl -H "X-API-Key: $SIFTING_KEY" \
  "https://api.sifting.io/v1/last/quote/forex/EURUSD"

The quote carries the bid and ask prices with their sizes and a timestamp in Unix epoch milliseconds, the same field names as the WebSocket tick frames: b and a for the prices, B and A for the sizes, t for the timestamp. One detail that will bite you on the first run: the snapshot returns the prices as JSON strings ("b": "1.17034"), not numbers, so cast them to float before any arithmetic. Here's a complete checker in Python:

import time
import requests

BASE = "https://api.sifting.io/v1"
HEADERS = {"X-API-Key": "sft_your_key_here"}

def check_quote(venue, symbol, max_spread_bps=5.0, max_age_ms=5000):
    resp = requests.get(f"{BASE}/last/quote/{venue}/{symbol}", headers=HEADERS)
    resp.raise_for_status()
    q = resp.json()

    # Prices come back as strings; the timestamp is a number. Coerce first.
    bid = float(q["b"])
    ask = float(q["a"])
    ts = float(q["t"])

    if bid <= 0 or ask <= 0:
        return {"ok": False, "reason": "one side of the book is missing"}
    if bid >= ask:
        return {"ok": False, "reason": f"crossed quote: bid {bid} >= ask {ask}"}

    mid = (bid + ask) / 2
    spread_bps = (ask - bid) / mid * 10_000
    age_ms = time.time() * 1000 - ts

    if spread_bps > max_spread_bps:
        return {"ok": False, "reason": f"wide spread: {spread_bps:.2f} bps"}
    if age_ms > max_age_ms:
        return {"ok": False, "reason": f"stale quote: {age_ms / 1000:.1f}s old"}

    return {"ok": True, "mid": mid, "spread_bps": round(spread_bps, 3)}

print(check_quote("forex", "EURUSD", max_spread_bps=2.0))
print(check_quote("crypto", "BTCUSD", max_spread_bps=10.0))

The function runs four checks in order of severity. A missing side and a crossed quote are structural failures: don't show the price at all. Wide and stale are quality failures: you might still show the price, but grayed out, with a warning, or excluded from downstream calculations. Returning a reason string instead of a bare boolean pays off the first time you have to work out why a symbol vanished from a dashboard at 3 a.m.

Picking thresholds that hold up#

The tempting shortcut is one global number. It won't survive contact with real data. A threshold loose enough to never false-positive on a small token will never fire on EURUSD, and a threshold tuned for FX majors will flag half of DeFi.

Two approaches work in practice. The static approach is a threshold per asset class or per symbol, set from observation: pull quotes for your watchlist every few seconds for a day, look at the distribution of spread_bps, and put the cutoff a comfortable multiple above the typical value. The adaptive approach makes that automatic. Keep a rolling window of the last N spreads per symbol in memory, take the median, and flag any quote whose spread exceeds some multiple of it (3x is a reasonable starting point). The median matters here; a mean gets dragged upward by the very outliers you're trying to catch.

Staleness thresholds follow the same logic. Crypto trades around the clock, so a quote more than a few seconds old deserves suspicion. FX quotes age normally over the weekend, and a stock quote outside regular hours can be old and still be the correct last quote. Which leads directly to the pitfalls.

Common pitfalls#

Milliseconds versus seconds. The timestamp field t is Unix epoch milliseconds, while Python's time.time() returns seconds. Mix them without converting and the age calculation is off by a factor of 1,000: either every quote looks stale or no quote ever does, depending on which direction you got it wrong. Both failure modes are silent. Normalize to one unit at the boundary (the code above multiplies time.time() by 1000) and write one test that pushes a known-fresh timestamp through the check.

A closed market is not a broken feed. An FX quote pulled on Saturday can be many hours old and still be the legitimate last quote. If your staleness alert doesn't know the market schedule, it will page someone every single weekend. Check GET /v1/fnd/markets/forex/status (or the matching market slug for the asset class) and suppress staleness alerts while the market is closed.

A 503 with stale_snapshot is information, not an outage. SiftingIO runs its own freshness guard server-side. When the latest data for an instrument is older than the freshness threshold, the snapshot endpoints return 503 with the error code stale_snapshot, and the body includes last_t and server_now so you can see exactly how old the data is. Retrying in a tight loop won't make the data fresher and burns your rate limit. Treat it as a data-quality signal: back off, check market status, and surface the condition the same way you'd surface your own staleness flag.

Where to go from here#

The snapshot check above is the polling version. If you stream ticks over the WebSocket instead, the same math applies frame by frame, and each tick also carries an explicit quality flag alongside the price, so you can combine your own spread check with the feed's own assessment. Either way the pattern holds: never pass a quote to a user or a model without asking how wide it is and how old it is. The full field reference for quotes and ticks is in the documentation. Read the docs

Keep reading

Related posts