NautilusTrader
Tutorials

Backtest with Polymarket Order Book Data (FinFeed)

Retrieve historical Polymarket L2 order book data through FinFeed, cache it in a Parquet catalog, and run an order book imbalance backtest. This example uses the Yes outcome of a daily Bitcoin price threshold market. The replay ends before expiry and closes the simulated position without relying on a settlement payout.

View source on GitHub.

Prerequisites

  • Python 3.13, 3.14, or 3.15.
  • NautilusTrader 2.x with the FinFeed adapter: pip install -U --pre nautilus_trader.
  • pandas: pip install pandas.
  • A FinFeed key with Prediction Markets historical order book access and sufficient allowance for at least three requests on the first run, including two book pages of up to 10,000 records each. See FinFeed authentication.

From a source checkout, run the tutorial from the repository root after building the Python extension:

uv run --project python --no-sync --with pandas \
    python docs/tutorials/backtest_orderbook_finfeed.py
import asyncio
import json
import os
import shutil
from collections.abc import Sequence
from datetime import UTC
from datetime import datetime
from decimal import Decimal
from getpass import getpass
from hashlib import sha256
from pathlib import Path
from tempfile import mkdtemp

import pandas as pd

from nautilus_trader.adapters.finfeed import FinFeedDataLoader
from nautilus_trader.backtest import BacktestNode
from nautilus_trader.common import LogLevel
from nautilus_trader.config import BacktestDataConfig
from nautilus_trader.config import BacktestEngineConfig
from nautilus_trader.config import BacktestRunConfig
from nautilus_trader.config import BacktestVenueConfig
from nautilus_trader.config import DataCatalogConfig
from nautilus_trader.config import DataEngineConfig
from nautilus_trader.config import LoggerConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.model import AccountType
from nautilus_trader.model import BinaryOption
from nautilus_trader.model import BookAction
from nautilus_trader.model import BookType
from nautilus_trader.model import InstrumentId
from nautilus_trader.model import NautilusDataType
from nautilus_trader.model import OmsType
from nautilus_trader.model import OrderBook
from nautilus_trader.model import OrderBookDelta
from nautilus_trader.model import Quantity
from nautilus_trader.model import RecordFlag
from nautilus_trader.model import Venue
from nautilus_trader.persistence import ParquetDataCatalog
from nautilus_trader.trading import OrderBookImbalanceConfig

Select the outcome and historical window

FinFeed identifies each outcome separately. The _YES suffix selects the Yes shares, not the whole event or its No shares. Keep the provider's ID under FINFEED_POLYMARKET; native Polymarket execution uses different IDs. The adapter loads the checked BinaryOption definition, including its price increment, share precision, and pUSD currency. The definition reflects metadata available at collection time; it does not reconstruct historical venue trading rules. FinFeed sets this outcome's EndDateIso to 00

UTC on November 3. The adapter uses that value as its simulated trading cutoff, so this example selects November 2. The cutoff does not establish actual resolution time or payout.

The first run reads FINFEED_API_KEY or prompts without echoing it. Later runs use the cached catalog without credentials or network access. Data stays under NAUTILUS_DATA_DIR/FinFeed, defaulting to ~/Downloads/Data/FinFeed. In a notebook with an active event loop, replace asyncio.run(collect_book(api_key)) below with await collect_book(api_key).

MARKET_ID = "BITCOIN-ABOVE-76K-ON-NOVEMBER-3-2026_YES"
INSTRUMENT_ID = InstrumentId.from_str(f"{MARKET_ID}.FINFEED_POLYMARKET")
START = datetime(2026, 11, 2, 20, 0, 5, tzinfo=UTC)
END = datetime(2026, 11, 2, 20, 2, tzinfo=UTC)
DATA_DIR = Path(os.environ.get("NAUTILUS_DATA_DIR", "~/Downloads/Data")).expanduser() / "FinFeed"
catalog_path = DATA_DIR / "btc-above-76k-nov3-20261102-20"
book_type = BookType.L2_MBP

Download historical deltas

FinFeedDataLoader fetches and retains the outcome definition, then converts historical book records into Nautilus deltas. It shares the adapter's pagination and snapshot reconstruction code. No node or actor is needed. The first run uses one metadata request and two book pages, plus any retries. The local attempt allowance does not establish provider billing units or remaining account quota.

Book history starts with covering UTC-hour selectors and reconciles overlapping 10,000-record pages. Source order and exchange timestamps stay intact; a derived monotone ts_init controls replay bounds. It does not measure receipt time. The start hour must contain a snapshot at or before START on that clock. Missing seeds, ambiguous continuation, exhausted allowance, and unsupported actions reject the request. See historical book limits. Exceptions propagate directly to the caller.

async def collect_book(api_key: str) -> tuple[BinaryOption, list[OrderBookDelta]]:
    """
    Download the outcome definition and its historical book deltas.
    """
    loader = FinFeedDataLoader(api_key=api_key)

    instrument = await loader.request_instrument(INSTRUMENT_ID)
    deltas = await loader.request_book_deltas(
        INSTRUMENT_ID,
        start=pd.Timestamp(START).value,
        end=pd.Timestamp(END).value,
    )
    return instrument, deltas


def book_digest(deltas: Sequence[OrderBookDelta], instrument: BinaryOption) -> str:
    """
    Hash delta values with the instrument's precision, including zero CLEAR fields.
    """
    digest = sha256()

    for delta in deltas:
        record = delta.to_dict()
        record["order"]["price"] = format(
            delta.order.price.as_decimal(),
            f".{instrument.price_precision}f",
        )
        record["order"]["size"] = format(
            delta.order.size.as_decimal(),
            f".{instrument.size_precision}f",
        )
        digest.update(json.dumps(record, sort_keys=True).encode())
    return digest.hexdigest()

Cache the response and reconstruct the book

Publish a new catalog only after the full request succeeds. A failed collection leaves no reusable cache. The manifest records the selection and a SHA-256 digest of the normalized deltas. Each cached replay checks that digest before running the strategy.

The adapter initializes the series with CLEAR and snapshot levels. Apply deltas in their original order and check book integrity at every F_LAST boundary. Only completed batches supply the preview and signal.

A successful reconstruction validates the returned events. It does not establish complete venue coverage: replay relies on the provider's snapshot and update semantics, and missing source events can leave gaps. FinFeed polling is a separate, sampled data path and is not used here.

DATA_DIR.mkdir(parents=True, exist_ok=True)
selection = {"market_id": MARKET_ID, "start": START.isoformat(), "end": END.isoformat()}

if not catalog_path.exists():
    api_key = os.environ.get("FINFEED_API_KEY") or getpass("FinFeed Prediction Markets API key: ")
    instrument, deltas = asyncio.run(collect_book(api_key))
    del api_key
    partial_path = Path(mkdtemp(prefix="finfeed-catalog-", dir=DATA_DIR))
    try:
        catalog = ParquetDataCatalog(str(partial_path))
        catalog.write_instruments([instrument])
        catalog.write_order_book_deltas(deltas)

        manifest = {
            **selection,
            "deltas": len(deltas),
            "sha256": book_digest(deltas, instrument),
        }

        (partial_path / "manifest.json").write_text(
            json.dumps(manifest, indent=2),
            encoding="utf-8",
        )
        del catalog
        partial_path.rename(catalog_path)
    finally:
        if partial_path.exists():
            shutil.rmtree(partial_path)

catalog = ParquetDataCatalog(str(catalog_path))
manifest = json.loads((catalog_path / "manifest.json").read_text(encoding="utf-8"))
assert {key: manifest[key] for key in selection} == selection
[instrument] = catalog.instruments(instrument_ids=[str(INSTRUMENT_ID)])
deltas = catalog.query_order_book_deltas(identifiers=[str(INSTRUMENT_ID)])
assert instrument.id == INSTRUMENT_ID
assert instrument.outcome == "Yes"
assert instrument.expiration_ns > pd.Timestamp(END).value
assert len(deltas) == manifest["deltas"]
assert deltas[0].action == BookAction.CLEAR
assert RecordFlag.F_SNAPSHOT.matches(deltas[0].flags)
assert RecordFlag.F_LAST.matches(deltas[-1].flags)

assert book_digest(deltas, instrument) == manifest["sha256"]
book = OrderBook(INSTRUMENT_ID, book_type)
top_of_book_rows = []
batch_count = 0

for delta in deltas:
    book.apply_delta(delta)
    if not RecordFlag.F_LAST.matches(delta.flags):
        continue
    book.check_integrity()
    batch_count += 1
    bid = book.best_bid_price()
    ask = book.best_ask_price()
    bid_size = book.best_bid_size()
    ask_size = book.best_ask_size()
    if bid is None or ask is None or bid_size is None or ask_size is None:
        continue

    if ask <= bid:
        continue
    bid_qty = bid_size.as_decimal()
    ask_qty = ask_size.as_decimal()
    top_of_book_rows.append(
        {
            "timestamp": delta.ts_init,
            "bid": bid.as_decimal(),
            "ask": ask.as_decimal(),
            "spread": ask.as_decimal() - bid.as_decimal(),
            "bid_size": bid_qty,
            "ask_size": ask_qty,
            "imbalance": (bid_qty - ask_qty) / (bid_qty + ask_qty),
        },
    )

assert batch_count > 1
assert top_of_book_rows
assert book.best_bid_price() is not None
assert book.best_ask_price() is not None
assert book.spread() > 0

top_of_book = pd.DataFrame(top_of_book_rows)
top_of_book.index = pd.to_datetime(top_of_book.pop("timestamp"), utc=True)
print(f"Catalog: {catalog_path}")
print(f"Instrument: {instrument.id}, outcome: {instrument.outcome}")
print(f"Loaded {len(deltas):,} deltas in {batch_count:,} completed batches")
print(f"Delta SHA-256: {manifest['sha256']}")
print(top_of_book.head().to_string())
print(f"Spread range: {top_of_book['spread'].min()} to {top_of_book['spread'].max()} pUSD")

Configure the backtest

Use the built-in Rust OrderBookImbalance through Python, as in the other book tutorials. When the smaller best-quote size is below 20% of the larger and the larger exceeds 100 shares, it sends a fill-or-kill limit order against the thinner side. Each order is at most one share, with at least one second between triggers.

Start a cash account with 1,000 pUSD. Buys acquire Yes shares; sells reduce inventory. The matching engine rejects a sell that exceeds the shares held. The strategy can therefore produce rejected orders when its signal asks to sell without inventory. An opposing signal does not buy No shares. The final market sell closes any remaining Yes position.

Enable emit_quotes_from_book so the risk engine can price the closing market order. This teaching run sets maker and taker fees to zero and assumes zero execution latency. It does not model live order routing, venue fees, queue position, or settlement. Simulated fills and PnL do not establish an edge or predict live execution.

config = BacktestRunConfig(
    engine=BacktestEngineConfig(
        logging=LoggerConfig(stdout_level=LogLevel.ERROR),
        data_engine=DataEngineConfig(emit_quotes_from_book=True),
    ),
    data=[
        BacktestDataConfig(
            catalog=DataCatalogConfig(path=str(catalog_path)),
            data_type=NautilusDataType.OrderBookDelta,
            instrument_id=instrument.id,
        ),
    ],
    venues=[
        BacktestVenueConfig(
            name="FINFEED_POLYMARKET",
            oms_type=OmsType.NETTING,
            account_type=AccountType.CASH,
            base_currency=instrument.currency,
            starting_balances=["1000 pUSD"],
            book_type=book_type,
            fee_model=MakerTakerFeeModel(maker_rate=Decimal(0), taker_rate=Decimal(0)),
        ),
    ],
    dispose_on_completion=False,
)

strategy_config = OrderBookImbalanceConfig(
    instrument_id=instrument.id,
    book_type=book_type,
    max_trade_size=Quantity.from_str("1.000000"),
    trigger_min_size=Decimal("100.0"),
    trigger_imbalance_ratio=Decimal("0.20"),
    min_seconds_between_triggers=1.0,
)

Run the backtest and inspect the results

The reference run loads 13,900 deltas in 13,774 completed batches from November 2, 2026, between 20:00

and 20:02
UTC. The spread ranges from 0.0050 to 0.0230 pUSD. Its normalized delta SHA-256 is:

df25d4f73bb70f3543407902776e4acaeb26cb630cef8d6bf1b21e0060322230

Of 37 orders, two fill and 35 are rejected. The fills are:

Time (UTC)SideSharesPrice (pUSD)
20:00
.591842700
Buy1.0000000.7620
20:01
.811306200
Sell1.0000000.7440

Both are fill-or-kill limit orders. The sell closes the position before the replay ends, so shutdown sends no closing market order. The final balance is 999.982000 pUSD, a realized loss of 0.018000 pUSD with zero fees. Compare your digest before comparing these results; refreshed provider history or metadata can change the run.

Replay the full cached series so the initial snapshot precedes updates. Inspect order statuses, fills, positions, and cash balances together. Rejected orders have no fills, and the final position must be flat. Keep the printed data digest with your results when comparing runs.

node = BacktestNode(configs=[config])
try:
    node.build()
    node.add_builtin_strategy(config.id, "OrderBookImbalance", strategy_config)
    results = node.run()
    orders = node.generate_orders_report(config.id)
    executions = node.generate_fills_report(config.id)
    positions = node.generate_positions_report(config.id)
    account = node.generate_account_report(config.id, venue=Venue("FINFEED_POLYMARKET"))
    print(results)
    print(orders["status"].value_counts().to_string())
    print(executions[["order_side", "order_type", "last_qty", "last_px", "commission"]].head())
    print(
        positions[
            [
                "side",
                "quantity",
                "peak_qty",
                "avg_px_open",
                "avg_px_close",
                "realized_pnl",
            ]
        ].to_string(),
    )
    print(account[["total", "locked", "free", "currency"]].tail().to_string())
    assert not positions.empty, "No positions opened; inspect the selected window and signal"
    assert positions["ts_closed"].notna().all(), "The strategy left an open position"
    assert positions["side"].eq("FLAT").all()
    assert positions["quantity"].map(Decimal).eq(Decimal(0)).all()
finally:
    node.dispose()

Visualize the run

These panels use the same cached series, configuration, and native reports as the backtest above. Lines show successive states as steps; fill markers show individual executions.

Best bid and ask with the one-share buy and sell fills

Figure 1. The strategy buys at the ask and sells at the bid about four seconds later. Both executions use the reconstructed book.

Spread, best-quote sizes, and signed size imbalance over the replay

Figure 2. Large ask sizes often favor a sell signal, but this cash account can only sell shares it holds. The plotted signed imbalance is (bid_size - ask_size) / (bid_size + ask_size); the strategy compares the smaller size with the larger size instead.

Yes-share inventory rises to one share and returns to zero

Figure 3. The position holds one Yes share for about four seconds and stays flat for the rest of the replay.

Equity marked at the midpoint ends at a loss of 0.018000 pUSD

Figure 4. The equity panel adds cash to the shares held at the current midpoint. Cash changes from buying shares are not realized losses. Once the position closes, the equity change equals this run's net PnL.

Regenerate the panels

Panel generation uses Plotly and Kaleido. Kaleido also requires a Chrome-compatible browser. From a source checkout, run:

uv run --project python --no-sync --with pandas --with plotly --with kaleido \
    python docs/tutorials/assets/backtest_orderbook_finfeed/render_panels.py

If Kaleido reports that Chrome is missing, install its browser once with:

uv run --project python --no-sync --with plotly --with kaleido plotly_get_chrome -y

The renderer writes PNGs to docs/tutorials/assets/backtest_orderbook_finfeed/. It reuses the cache or follows the first-collection procedure above. All calculations use exact decimals until conversion to plotting coordinates.

On this page