AymarData

Step-by-step guide to backtesting Polymarket BTC/ETH Up/Down markets with AymarData L2 books, trades, and settlements — including Python and cURL examples.

How to backtest Polymarket Up/Down markets

Polymarket Up/Down contracts are short-dated binary markets that resolve on whether an asset (for example BTC) finishes above or below a reference price over a fixed interval — commonly 5m, 15m, 1h, 4h, or 24h. Traders and researchers backtest these markets to measure edge from mid-price signals, order-book imbalance, and cross-venue crypto prices.

This guide shows how to pull historical L2 books, trades, and settlements from AymarData and run a simple, reproducible backtest in Python. It uses the same markets documented on the Polymarket data hub and BTC Up/Down 15m pages.

What you need

  • An AymarData API key (ad_live_… from the dashboard)
  • Python 3.10+ with requests
  • A clear market family and interval (start with btc-updown-15m)

See Authentication and the Quickstart if you have not created a key yet. Plans and rate limits are on Pricing.

Market identifiers

Up/Down markets are identified by a family slug plus a window timestamp embedded in the full market id. Examples:

FormExample
Family (data page)btc-updown-15m
Concrete windowbtc-updown-15m-1792681200
Outcomeup or down

List windows for a source with GET /v1/markets:

export AYMARDATA_API_KEY=ad_live_YOUR_KEY

curl -s "https://api.aymardata.com/v1/markets" \
  -H "Authorization: Bearer $AYMARDATA_API_KEY" \
  -G \
  --data-urlencode "source=polymarket" \
  --data-urlencode "q=btc-updown-15m" \
  --data-urlencode "interval=15m" \
  --data-urlencode "limit=20"

Use the returned market slug (or condition / token id) for book, trade, and settlement calls. When a market is two-sided, always pass outcome=up or outcome=down.

Interpreting market list fields

Typical rows include identifiers, human titles, interval, status (open / closed / resolved-like states depending on normalization), and history_from when AymarData has retained book history for that window. Prefer concrete window ids for historical work: family slugs are convenient for “current” lookups but can resolve ambiguously when many windows share a prefix.

If history_from is missing, either the collector has not retained that window yet or the market is too new. Skip those rows in a research loop rather than inventing timestamps. Coverage start dates for families also appear on each data page.

Pull a point-in-time book

Backtests usually need the book as of a timestamp inside the window, not only the latest snapshot. AymarData reconstructs L2 from checkpoints plus deltas — see GET /v1/book and order book deltas vs snapshots.

curl -s "https://api.aymardata.com/v1/book" \
  -H "Authorization: Bearer $AYMARDATA_API_KEY" \
  -G \
  --data-urlencode "source=polymarket" \
  --data-urlencode "market=btc-updown-15m" \
  --data-urlencode "outcome=up" \
  --data-urlencode "at=2026-10-22T14:30:00Z" \
  --data-urlencode "depth=10"

Python equivalent:

import os
import requests

API = "https://api.aymardata.com/v1"
KEY = os.environ["AYMARDATA_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}

def book(market: str, outcome: str, at: str, depth: int = 10):
    r = requests.get(
        f"{API}/book",
        headers=H,
        params={
            "source": "polymarket",
            "market": market,
            "outcome": outcome,
            "at": at,
            "depth": depth,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

snap = book("btc-updown-15m", "up", "2026-10-22T14:30:00Z")
best_bid = snap["bids"][0]["price"] if snap["bids"] else None
best_ask = snap["asks"][0]["price"] if snap["asks"] else None
mid = (best_bid + best_ask) / 2 if best_bid is not None and best_ask is not None else None
print(snap["spread"], mid, len(snap["bids"]), len(snap["asks"]))

Implied probability for the Up outcome is often approximated by the mid (or a size-weighted mid). Treat that as a model input, not a risk-free fair value — fees, inventory, and latency still matter.

When to use /v1/book vs /v1/book/deltas

GoalEndpointWhy
Sparse signals (1 sample / window)/v1/bookOne reconstructed snapshot is enough
Path-dependent exits inside the window/v1/book/deltasAvoid polling full depth every second
Offline multi-day training/v1/filesPro/Enterprise bulk Parquet/CSV

For a first Up/Down screen, stick to point-in-time books plus settlements. Graduate to deltas only when your signal needs the path of mid or imbalance inside the interval.

Trades and fills

Use GET /v1/trades to measure realized flow and to mark-to-market synthetic fills against actual prints:

def trades(market: str, outcome: str, start: str, end: str, limit: int = 200):
    r = requests.get(
        f"{API}/trades",
        headers=H,
        params={
            "source": "polymarket",
            "market": market,
            "outcome": outcome,
            "start": start,
            "end": end,
            "limit": limit,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]

A minimal strategy might buy Up when mid < 0.45 early in the window and exit before settlement, or hedge using Binance spot/perp mid from the Binance data hub. Keep execution assumptions explicit: use ask when buying, bid when selling, and optionally add a fixed fee in probability points.

Fill realism pitfalls

  1. Crossing the book — Mid-only entries understate cost; always record entry as ask (buy) or bid (sell) unless you model passive queue priority.
  2. One-sided books — Early or late in a window, one side can be empty. Skip or treat as no-trade rather than forcing a mid of None.
  3. Size vs signal — A mid of 0.40 with 50 shares at the ask is not the same as 50,000 shares. Cap synthetic fill size by top-of-book size or by a fixed fraction of displayed depth.
  4. Trade-print lag — Last trade can sit inside a wide spread; do not use last trade as “fair” for entry simulation when a two-sided book exists.

Settlements

Without settlement, P&L is incomplete. Fetch resolution with GET /v1/settlement:

def settlement(market: str):
    r = requests.get(
        f"{API}/settlement",
        headers=H,
        params={"source": "polymarket", "market": market},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

Map the winning outcome to your position. For a long Up position sized in shares, terminal value is roughly 1.0 if Up wins and 0.0 if Down wins (before fees). Document how your backtest treats partial fills and whether you allow holding through resolution.

Settlement payloads typically identify the winning outcome and timing metadata. Normalize outcome strings to lowercase before comparing to your position side. Unresolved windows should be excluded from P&L aggregates — do not treat HTTP success with a missing outcome as a loss.

A compact backtest loop

The sketch below walks windows returned by /v1/markets, samples the Up book once near the open, takes a discretionary long if mid is below a threshold, and scores against settlement. Replace the signal with your own model; keep the data plumbing.

from datetime import datetime, timezone, timedelta

THRESHOLD = 0.45

def list_markets(q: str, limit: int = 50):
    r = requests.get(
        f"{API}/markets",
        headers=H,
        params={"source": "polymarket", "q": q, "interval": "15m", "limit": limit},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["data"]

def sample_at(history_from: str, offset_s: int = 60) -> str:
    t = datetime.fromisoformat(history_from.replace("Z", "+00:00"))
    return (t + timedelta(seconds=offset_s)).astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

def backtest_family(q: str = "btc-updown-15m"):
    rows = []
    for m in list_markets(q):
        market = m["market"]
        hf = m.get("history_from")
        if not hf:
            continue
        at = sample_at(hf, 60)
        try:
            snap = book(market, "up", at, depth=5)
            if not snap["bids"] or not snap["asks"]:
                continue
            mid = (snap["bids"][0]["price"] + snap["asks"][0]["price"]) / 2
            entry = snap["asks"][0]["price"]  # buy Up at ask
            if mid >= THRESHOLD:
                continue
            settle = settlement(market)
            outcome = str(settle.get("outcome", "")).lower()
            if outcome not in ("up", "down"):
                continue
            win = outcome == "up"
            pnl = (1.0 - entry) if win else (0.0 - entry)
            rows.append({"market": market, "mid": mid, "entry": entry, "pnl": pnl, "win": win})
        except requests.HTTPError:
            continue
    return rows

results = backtest_family()
wins = sum(1 for r in results if r["win"])
print(f"{len(results)} resolved · {wins} Up wins")
# If you also print avg pnl / win rate / edge, prefix the line with:
# "Illustrative output — not a real result"

Illustrative output — not a real result (shape only; recompute with your key):

12 resolved · 5 Up wins

Do not treat avg-pnl, win-rate, or edge % from this sketch as an AymarData result. The loop is intentionally naive: one sample per window, no inventory limits, no cross-market hedging. Production research usually joins Binance or Hyperliquid mids, applies delta streams for path-dependent exits, and walk-forwards parameters. For higher-frequency paths, prefer book deltas on top of a checkpoint rather than polling full books every second.

Cross-venue context

Many Up/Down strategies use crypto spot or perp prices as features. AymarData also covers:

Align clocks deliberately: sample Polymarket and Binance at the same UTC at=. Store both ts_exchange and ts_recv when auditing latency between venues. See How to get Polymarket historical data for bulk/file options and Backtesting with an AI agent via MCP if you want Cursor or Claude to drive the same endpoints.

Joining a crypto mid in the same loop

def crypto_mid(source: str, market: str, at: str):
    r = requests.get(
        f"{API}/book",
        headers=H,
        params={"source": source, "market": market, "at": at, "depth": 1},
        timeout=30,
    )
    r.raise_for_status()
    b = r.json()
    if not b["bids"] or not b["asks"]:
        return None
    return (b["bids"][0]["price"] + b["asks"][0]["price"]) / 2

# Example feature at decision time
# feature = crypto_mid("binance-spot", "BTCUSDT", at)

Use binance-spot vs binance-perps explicitly — BTCUSDT exists on both and the books differ. Hyperliquid coins often use shorter ids such as BTC on /data/hyperliquid.

Evaluation checklist

Before trusting headline P&L:

  1. Hold out the newest N windows; tune only on the earlier set.
  2. Baseline against no-trade and random-entry (same size rules).
  3. Fee sensitivity — sweep a fee in probability points (for example 0, 0.5¢, 1¢) and report all three.
  4. Outcome balance — if your sample is heavily skewed toward Up resolutions, a long-Up bias looks profitable without edge.
  5. Coverage gaps — log skipped windows (empty book, missing settlement, HTTP errors) so attrition is visible.

Practical tips

  1. Pin timestamps in UTC and store ts_exchange / ts_recv from responses so you can audit latency.
  2. Always pass outcome for two-sided Polymarket books; Up and Down books are separate instruments.
  3. Respect rate limits documented in Rate limits; batch window discovery, then fetch books in controlled parallel pools.
  4. Validate on a short date range before scanning months of windows.
  5. Compare against a no-trade baseline and a random-entry baseline so apparent edge is not just skew in resolved outcomes.
  6. Prefer concrete window market ids once discovered; keep family slugs for discovery only.
  7. Document execution assumptions in the notebook header so later readers do not assume mid-fills.

From toy loop to research grade

Once the compact loop runs, harden it in layers rather than rewriting everything at once. Layer one adds skip metrics and fee sweeps. Layer two adds a crypto mid feature from Binance or Hyperliquid sampled at the same at=. Layer three replaces the single sample with a short path from book deltas so exits can depend on mid trajectories inside the window. Layer four moves discovery and settlement archives to local Parquet via bulk files when your plan includes them, keeping REST for ad hoc at= checks.

At each layer, freeze a holdout list of market ids and refuse to tune thresholds on it. Prediction-market samples are short and regime-heavy; without holdouts, a threshold of 0.45 will look brilliant until the next volatility week. If you use an AI agent to accelerate scaffolding, keep the agent on discovery and code generation — not on silently changing labels — as described in Backtesting with an AI agent via MCP.

FAQ

Can I backtest with only trades?
Yes for fill studies, but you will miss quoted depth and realistic entry prices. Prefer books + settlements for strategy P&L.

Do history windows expire?
Paid plans retain history; each data page shows coverage start. Confirm history_from on /v1/markets for the windows you care about.

Should I trade both Up and Down?
You can, but treat them as separate books. Naive “long Up and short Down” assumptions break when spreads, fees, and inventory differ across tokens.

Where do I go next?
Polymarket order book data explained · Docs · Pricing

Educational content, not trading or investment advice. Past data doesn't predict future results.