vike
GuidesPolymarket

Measure quoted spreads with DuckDB

Download one day of BTC 5-minute top-of-book quotes from the archive and compute median and p90 spread per token with DuckDB.

Top-of-book is the l1_quotes stream: bid, ask and both sizes, one row per change. The full-day file is hundreds of megabytes, so the recipe takes the family layout (asset=btc, tenor=5m), which is a much smaller file, downloads it whole, and only then hands the local path to DuckDB.

Run
pip install requests duckdb
export VIKE_API_KEY=...
python pm-spread-duckdb.py             # real run: needs the key, writes polymarket-btc-5m-l1-<date>.parquet
python pm-spread-duckdb.py --dry-run   # prints the requests it would make; needs neither duckdb nor a key
pm-spread-duckdb.py
"""Quoted-spread statistics for BTC 5-minute Polymarket markets from one downloaded day of l1_quotes, read with DuckDB.

Run:  VIKE_API_KEY=... python pm-spread-duckdb.py    (--dry-run lists the requests and makes none)
Needs `pip install duckdb` for the real run; --dry-run does not.
"""
import os
import sys
from urllib.parse import urlencode

NAME = "pm-spread-duckdb"
BASE = "https://data.vike.io"
DRY = "--dry-run" in sys.argv


def fail(why):
    sys.exit(f"{NAME}: {why}")


def call(path, method="GET", **params):
    url = BASE + path + ("?" + urlencode(params) if params else "")
    if DRY:
        print(method, url)
        return None
    key = os.environ.get("VIKE_API_KEY") or fail("set VIKE_API_KEY")
    import requests

    r = requests.request(method, url, headers={"X-API-Key": key}, timeout=60)
    if r.status_code != 200:
        fail(f"{method} {path} answered HTTP {r.status_code}: {r.text[:200]}")
    return r.json()


def download(url, path):
    """The whole file in one request, streamed to disk. DuckDB then reads the LOCAL copy, never the URL."""
    if DRY:
        print("GET", url)
        return
    import requests
    from urllib.parse import urlparse

    # The key must only ever go to our own host, whatever URL the API handed back.
    if urlparse(url).hostname != "data.vike.io":
        fail(f"refusing to send the API key to {url}")
    with requests.get(url, headers={"X-API-Key": os.environ["VIKE_API_KEY"]}, stream=True, timeout=300) as r:
        if r.status_code != 200:
            fail(f"GET {url} answered HTTP {r.status_code}")
        with open(path, "wb") as f:
            for chunk in r.iter_content(chunk_size=1 << 20):
                f.write(chunk)


def main():
    # The family layout: BTC 5-minute markets only, a much smaller file than the flat day of l1_quotes.
    family = dict(asset="btc", tenor="5m", stream="l1_quotes")
    index = call("/v1/polymarket/archive/datasets", limit=1000, **family)
    day = "2026-09-01"  # --dry-run has no response to read
    if not DRY:
        if not index["datasets"]:
            fail("the archive index lists no btc/5m l1_quotes file")
        day = max(d["date"] for d in index["datasets"])

    meta = call("/v1/polymarket/archive/url", date=day, **family)
    url = f"{BASE}/archive/venue=polymarket/asset=btc/tenor=5m/date={day}/l1_quotes.parquet"  # --dry-run: the shape /archive/url returns
    if not DRY:
        url = meta["url"]
    path = f"polymarket-btc-5m-l1-{day}.parquet"
    download(url, path)
    if DRY:
        return
    if os.path.getsize(path) != meta["bytes"]:
        fail(f"{path} is {os.path.getsize(path)} bytes, the archive says {meta['bytes']} (truncated download?)")

    import duckdb

    # bid/ask are Decimal(9,4); cast so the arithmetic is plain floats. One row per top-of-book CHANGE, so these
    # are statistics over quote updates, not time-weighted spreads.
    rows = duckdb.execute(
        """
        SELECT token_id, count(*) AS quotes,
               median(CAST(ask AS DOUBLE) - CAST(bid AS DOUBLE)) AS median_spread,
               quantile_cont(CAST(ask AS DOUBLE) - CAST(bid AS DOUBLE), 0.9) AS p90_spread
        FROM read_parquet(?)
        WHERE bid > 0 AND ask > 0 AND ask >= bid
        GROUP BY token_id ORDER BY quotes DESC LIMIT 5
        """,
        [path],
    ).fetchall()
    if not rows:
        fail(f"{path} holds no usable quotes")
    print(f"{day}, BTC 5m, five most-quoted tokens")
    for token_id, quotes, median, p90 in rows:
        print(f"  {token_id[:12]}...  {quotes:>7} quotes  median spread {median:.4f}  p90 {p90:.4f}")


if __name__ == "__main__":
    try:
        main()
    except Exception as e:  # a shape the recipe does not expect fails by name, not by traceback
        fail(f"{type(e).__name__}: {e}")

Read it this way

  • Local path only. read_parquet is given the file on disk. Pointing it at the archive URL turns one file into thousands of Range requests; Downloading measures that.
  • The newest date comes from the family index, not from the flat one: the family layout is additive and does not cover every date the flat layout does, so the script enumerates datasets rather than assuming.
  • bid and ask are Decimal(9,4). The query casts them to DOUBLE before subtracting, and drops rows with a zero side or a crossed book.
  • These are statistics over quote updates. Each row is a change, so a spread that persists for an hour counts once. A time-weighted figure needs the ts column (epoch milliseconds) and a join of each quote to the next.

A missing file is an answer, not an error

A family with zero rows in a stream on a day has no file for it. The script picks a date the index lists, so it cannot hit that; a script that guesses dates will, as a 400 from /archive/url.

On this page