# outcometick backtest — full documentation > An open-source tick-level backtester for Polymarket, in plain Python (also JavaScript/TypeScript): any binary market (elections, sports, crypto) from order-book data you recorded, or a ready-made archive of Polymarket and Predict.fun crypto Up/Down markets. Strategies are replayed against the order book event by event, fills walk the depth, markets settle at $1/$0 only on evidence of the outcome, and every fill pays the venue's taker fee. `pip install outcometick`; a free real archive day via `load_sample()`. Source: https://outcometick.github.io/polymarket-backtest/ --- # For AI agents You are probably here because someone asked you to backtest a prediction-market strategy. This page is the short path: what this package does, the smallest program that runs, the rules that decide whether a result is right, and how to reach the data and the hosted backtester from a coding tool. The whole site as one plain-text file: [llms-full.txt](https://outcometick.github.io/polymarket-backtest/llms-full.txt); an index: [llms.txt](https://outcometick.github.io/polymarket-backtest/llms.txt). Every page is also served as Markdown — add `.md` to its path (for example [methodology.md](methodology.md)). ## What it is `outcometick.backtest` is a general tick-level backtester for Polymarket: any binary market (elections, sports, crypto) from order-book data you recorded — Polymarket's CLOB frames or a table, see [Your own data](index.md#your-own-data-any-polymarket-binary-market) — or a ready-made archive of Polymarket and Predict.fun crypto Up/Down markets (5- and 15-minute on Polymarket; Predict.fun also hourly and daily), with one free day via `load_sample()`. Each market settles at $1 / $0 only on evidence of its outcome (for the archive's crypto markets, the Chainlink stream that market settled on), and every fill pays the venue's taker fee. Pure Python 3.10+; the same engine runs in JavaScript (`npm i -g outcometick`, `ot run`) and on the hosted backtester, with identical results. ## Smallest program that runs ```bash pip install outcometick ``` ```python from outcometick import Strategy, Order from outcometick.backtest import run, load_sample class Favourite(Strategy): def on_market_open(self, ctx, market): self.close, self.done = market.close_ts_ms, False def on_tick(self, ctx, tick): if self.done or self.close - ctx.now > 60_000: return None book = ctx.book() up, down = book.best("UP"), book.best("DOWN") if up is None or down is None: return None self.done = True side, price = ("UP", up) if up >= down else ("DOWN", down) return Order(side=side, notional=50, limit=0.95) if 0.55 <= price <= 0.95 else None result = run(Favourite, load_sample()) # one real archive day, downloaded once, no key print(result.summary()) ``` `load_sample()` needs no account. More days come from the archive (a data subscription) unpacked to a directory and passed as `data`. ## The shape of a strategy - A class with hooks: `on_market_open(ctx, market)`, `on_tick(ctx, tick)` (settlement-stream price), `on_book(ctx, book)`, `on_trade(ctx, trade)`, `on_timer(ctx, timer)`, `on_settle(ctx, market, outcome)`. Declare the datasets the hooks need (`book`, `trades`, ...); `run()` infers them from the hooks you define. - A hook returns `None`, an `Order`, or a list of them. `Order(side="UP"|"DOWN", size=... or notional=..., limit=...)`; `tif="gtc"` rests a limit order (Polymarket, with `book` and `trades` declared). - `ctx`: `ctx.now`, `ctx.book()` (`best`, `best_bid`, `depth`, `levels`), `ctx.position()`, `ctx.history(n)`, `ctx.p` (params), `ctx.log()`, `ctx.schedule()` (timers), `ctx.spot()` / `ctx.twap_locked()` (with the `spot` dataset). Full reference: [outcometick.com/docs/sdk](https://outcometick.com/docs/sdk); the Python API: [API reference](api.md). - By default each market gets a fresh strategy instance (`mode="market"`); `mode="session"` keeps one instance across markets, replayed on one time line. ## Rules that decide whether a result is right - **No look-ahead.** Events arrive one at a time; a market's `outcome` is hidden until `on_settle`. Do not read archive files directly inside a strategy. - **Settlement is never approximated.** Markets pay on the official outcome; a market whose stream or outcome cannot be read is dropped and listed in `report["coverage"]`, not settled on a guess. - **Fills walk the book that was there**, level by level; size the book did not have is unfilled. Use `latency_ms` for order latency and `feed_latency` for when the strategy hears of an event. - **Fees are on by default** (each market's schedule). Turn them off only on purpose. - **Book resolution is the data's, not the engine's.** The archive's book is rebuilt from the delta stream at the cadence it was captured (per asset, 20-500 ms); the hosted backtester levels each asset to the coarsest cadence in the run's range, and `run(..., book_throttle=False)` replays the archive as captured. Your own data (`run(Strategy, Dataset or directory)`) is never thinned — high-frequency work belongs there. - **Check the sample size.** One day is ~289 five-minute markets per asset. Use `sweep(..., holdout=0.3)` to keep later days out of a parameter search. - **Estimates are labelled.** Queue models, maker rebates and `twap_locked()` are estimates; [Methodology](methodology.md) states each one's limits. [How we verify it](verification.md) says what is proven and what is not. ## From a coding tool (MCP) The npm package includes an MCP server that runs on the user's machine (stdio, Node 24): ```bash claude mcp add outcometick -- npx -y -p outcometick ot mcp ``` Tools: `coverage`, `plans`, `meta`, `list_files`, `download_file` (sha256-verified), `backtest_capacity`, `backtest_quote`, `check_strategy` (the hosted queue's validator, run locally), `backtest_runs`, `backtest_status`, `smart_plans`, `smart_coverage`, `smart_days`, `smart_download`. Public tools need no key; `OT_KEY` (data), `OT_SMART_KEY` (smart-money lists) and `OT_BACKTEST_KEY` (hosted backtests) unlock the rest. It never submits a backtest — that spends credits, so a person runs `ot submit`. Other clients: [outcometick.com/docs](https://outcometick.com/docs). ## Where things are - Python package and this repository: [github.com/outcometick/polymarket-backtest](https://github.com/outcometick/polymarket-backtest) - JavaScript / TypeScript and the `ot` CLI: [github.com/outcometick/polymarket-backtest-js](https://github.com/outcometick/polymarket-backtest-js) - The data these backtests replay: [Polymarket historical data](https://outcometick.com/polymarket-historical-data), [Polymarket order book data](https://outcometick.com/polymarket-order-book-data), [Chainlink settlement data](https://outcometick.com/chainlink-settlement-data), [Predict.fun historical data](https://outcometick.com/predict-fun-data) — the archive's own index: [outcometick.com/llms.txt](https://outcometick.com/llms.txt) - Run the same strategy on our machines: [the hosted backtester](https://outcometick.com/backtest) --- # outcometick backtest · Python **A tick-level backtester for Polymarket, in plain Python.** Any binary market — elections, sports, crypto, anything with two outcomes — from order-book data you recorded (Polymarket's own CLOB frames, or a table), or a free real archive day to start with. Your strategy is replayed against the order book event by event, fills walk the depth that was there, each market settles at $1 / $0 only on evidence of its outcome, and every fill pays the venue's taker fee — on your laptop, from a notebook, with no other dependencies. Market makers get resting limit orders with a queue model built on how Polymarket actually records trading, and the markouts to see whether the fills were worth having. **English** · [简体中文](https://outcometick.github.io/polymarket-backtest/zh/) · [Docs site](https://outcometick.github.io/polymarket-backtest/) · [For AI agents](https://outcometick.github.io/polymarket-backtest/agents/) · [Methodology](methodology.md) · [How we verify it](verification.md) · JavaScript / TypeScript: [polymarket-backtest-js](https://github.com/outcometick/polymarket-backtest-js) > Open source (MIT), maintained by [outcometick.com](https://outcometick.com), which also publishes a ready-made > tick-level archive of Polymarket and Predict.fun crypto Up/Down markets and a > [hosted backtester](https://outcometick.com/backtest) that runs the same strategy on its machines. You do not > need either. [More below.](#on-outcometickcom) ```bash pip install outcometick # Python 3.10+, nothing else ``` ```python from outcometick import Strategy, Order from outcometick.backtest import run, load_sample class Favourite(Strategy): """Buy the favourite in the last minute, if it is not already priced as a certainty.""" def on_market_open(self, ctx, market): self.close, self.done = market.close_ts_ms, False def on_tick(self, ctx, tick): if self.done or self.close - ctx.now > 60_000: return None book = ctx.book() up, down = book.best("UP"), book.best("DOWN") if up is None or down is None: return None self.done = True side, price = ("UP", up) if up >= down else ("DOWN", down) if not 0.55 <= price <= 0.95: return None return Order(side=side, notional=50, limit=0.95) result = run(Favourite, load_sample()) # one real archive day, downloaded once, no key print(result.summary()) result.plot() # needs matplotlib result.trades_df() # every trade (needs pandas) ``` That runs on a free day of the archive, so it needs no data of your own. To run the same class on markets you recorded — any Polymarket binary market — pass your data instead: ```python from outcometick.backtest import run, Dataset result = run(Favourite, "my-polymarket-data") # markets.json + CLOB frames you recorded result = run(Favourite, Dataset.from_frames(gamma_markets, frames)) # or straight from memory ``` [Your own data](#your-own-data-any-polymarket-binary-market) below has the formats and the settlement rules. Decoding a day uses every CPU core, with the single-process result byte for byte. Pass `workers=8` (or however many cores you have) and the markets are replayed in parallel too — same rows, totals and logs, as long as the strategy keeps its state on the instance, which market mode's contract asks for anyway (state found on the class or in params makes the run replay in one process instead). On an 8-core laptop the free sample day (289 markets, 7.6 million events) takes about 30 s the first time and about 5 s after that with `workers=8`. The decoded day is cached, so the next run over it starts at once. [notebooks/quickstart.ipynb](https://github.com/outcometick/polymarket-backtest/blob/main/notebooks/quickstart.ipynb) walks through a full example with its output. ## What it gets right **Settlement is the venue's, not an approximation.** A market pays $1 or $0 only on evidence of its outcome: on your own data, Gamma's resolution, a `market_resolved` frame or the resolution you give, and they must agree; on the archive's crypto markets, the official outcome on the Chainlink stream that market settled on — the instantaneous feed, a 30-second TWAP or a 60-second TWAP, as its own config (`twapLookbackSeconds`) states, not as its date implies. Predict.fun ties pay 50:50. A market whose outcome cannot be read is dropped and listed in the report, never settled on a guess. **Two order books per market.** UP and DOWN are separate tokens with separate bids and asks. Buying UP lifts UP's asks; getting out sells into UP's bids. (Predict.fun publishes one book per market, so its DOWN side is the mirror of UP's.) **Fills walk the depth that was there.** An order is matched against the reconstructed book at that millisecond, level by level. Size the book did not have is reported as unfilled — it is never filled at the top of book. With `latency_ms`, the order matches against the book as it stood when it would have arrived, and a limit still protects it. **The venue's taker fee, market by market.** Polymarket charges takers `C × rate × p × (1 − p)`; each market's schedule is in the archive, and every simulated fill pays it. The report states the fee model used and how many markets had no readable schedule. It is an estimate in one respect, and the report says so: the venue rounds per maker order matched, the archive keeps book levels, so the fee is computed and rounded per level taken. **Resting orders, queued the way the venue trades.** `Order(..., tif="gtc")` rests at its limit behind the size already visible at that price. The queue model is built on three measured facts about the archive, not on a generic exchange: the book is *mirrored* (an UP bid at q and a DOWN ask at 1 − q are one queue, published twice), each trade prints *once, on the taker's token* (so a DOWN buyer taking resting UP bids — a mint — advances an UP bid's queue; a single-token model misses about half of what consumes it), and a print can arrive *after* the book update it caused. So a print fills a resting order only if its price reaches the order, out of one volume budget that first pays for depth the book removed just before it, at that depth's own price; a new order ignores prints for a short lag window, and a cancelled one stays exposed for it. Entry and cancel latency are separate (`latency_ms`, `cancel_latency_ms`), maker fills pay no fee (rebates are not modelled), and both engines agree fill for fill. The report adds what a market maker looks at: fill rate, queue position at entry and at fill, time in queue, and markouts 1s / 10s / 60s after each fill and to settlement — adverse selection, in numbers. **Parameter sweeps that admit when they are overfitted.** `sweep(Strategy, data, {"enter_at_s": [30, 60, 120], "px_max": [0.9, 0.95]}, holdout=0.3)` runs every combination, ranks them on the earlier days and replays the top ones on the later days it never ranked on, side by side. A grid search always finds a winner; the held-out column is where you find out whether it found an edge. `.table()` gives a DataFrame, `.heatmap(x, y)` the picture. `cell_workers=8` runs eight cells at a time on separate processes and returns the same sweep, byte for byte (16 cells over the sample day: 377 s one after another, 77 s on 8 cores). **No look-ahead by construction.** Events reach the strategy one at a time; the rest of the day is not in memory. A market's `outcome` is hidden until `on_settle`. In session mode, markets that overlap in time are replayed interleaved in one time order, so state carried across markets never runs ahead of the clock. **The same engine as the hosted backtester.** The replay engine here is byte for byte the one our sandbox runs for Python strategies; the archive decoder and the report are ports of the JavaScript ones the command line and the hosted queue use, checked against them event by event and row by row on the public archive samples and on synthetic archives built to hold the awkward cases. See [How we verify it](verification.md). **A report made for binary markets.** Brier score, calibration by entry price, edge per contract, peak capital at risk, a slippage panel, and naive baselines at the same trade size — always UP, always DOWN, always the favourite at the opening quote. If a strategy cannot beat buying the favourite, the report says so on page one. **Honest about the data.** The book is rebuilt from snapshots and deltas captured at a set cadence; the unthrottled best-bid/ask stream can only remove levels the market has moved past, never add one. Every market-day that could not be replayed is listed with the reason. [Methodology](methodology.md) has the details. **Plain classes.** No special dialect, no compilation step: `on_market_open`, `on_tick`, `on_book`, `on_trade`, `on_settle`, returning an `Order` or nothing. The same class runs here and in the hosted sandbox. ## The API ```python run(StrategyClass, data, # data: load_sample("polymarket" | "predict") or an archive directory assets=["BTC"], # default BTC, ETH, SOL, XRP days=["2026-10-18"], # default: every day in the archive intervals=["5m", "15m"], # default ["5m"] datasets=["settlement", "book", "trades", "bbo"], mode="market", # or "session": one instance across markets, one clock latency_ms=250, # decision-to-fill delay (also when a resting order arrives) cancel_latency_ms=None, # ctx.cancel() to venue; None: same as latency_ms fee_bps=None, # None: each market's venue fee; a number: flat override params={"enter_at_s": 60}, # read as ctx.p workers=None, # None: decode on every core, replay in one process; N: both on N fok=False, order_rules=None, # execution options, all off by default — see Methodology §10: maker_rebate=None, # fill-or-kill, venue order rules, maker rebate estimate, quote_guard=None, # quote freshness / ask-sum guard, shared mirrored depth, mirror_depth=None, book_throttle=None, # and (local only) the archive book's thinning on or off feed_latency=None, # ms before the strategy hears of an event (§11; on_timer + ctx.schedule too) queue_model=None, # resting orders: {"prob": "power", "n": 2} etc. instead of the conservative queue (§12) record_ms=None) # the account's value on a time grid, legs at the best bid (§13; Result.record) run_template("path/to/strategy-dir", data) # a directory with outcometick.json, like `ot run` fingerprint("path/to/strategy-dir", assets=["BTC"], days=days) # pre-registration: publish this sha256 before you run sweep(StrategyClass, data, {"enter_at_s": [30, 60, 120]}, # every combination, as plain runs metric="net_pnl", holdout=0.3, top=3, # rank on the earlier 70% of days, replay the top 3 on the rest cell_workers=None) # N: N cells at a time, the same result ``` Add `"spot"` to `datasets` for `ctx.spot()` and `ctx.twap_locked()`: how much of the settlement TWAP has already elapsed and at what level — an approximation from Chainlink's 1 Hz stream, with its measured error ([§14](methodology.md#14-spot-and-the-locked-part-of-the-settlement-twap-optional)). `Result` has `.report` (the same JSON the hosted backtester returns), `.trades`, `.fills`, `.markets` (every market replayed, entered or not), `.logs`, `.summary()`, `.plot()`, `.trades_df()`, `.fills_df()`, `.markets_df()`. `report["fingerprint"]` is the same sha256 `fingerprint()` printed before the run (Methodology §7). These calls run the strategy inside your process: for a fully isolated reproduction use `ot run` or the hosted backtester. A resting quote, and the numbers it is judged by: ```python class Quote(Strategy): def on_book(self, ctx, ev): bid = ctx.book().best_bid("UP") if bid is None or ctx.orders(): # one working quote at a time return None return Order(side="UP", size=20, limit=bid, tif="gtc", post_only=True, client_id="q") result = run(Quote, load_sample(), datasets=["settlement", "book", "trades"], latency_ms=150) result.report["maker"] # fill rate, queue ahead at join / at fill, markouts 1s / 10s / 60s / settle ``` `ctx.orders()` lists your working orders (no queue position — a venue does not tell you that); `ctx.cancel(id)` cancels one by its id or `client_id`. [examples/maker_quotes.py](https://github.com/outcometick/polymarket-backtest/blob/main/examples/maker_quotes.py) quotes both sides. ## Your own data: any Polymarket binary market The archive holds crypto Up/Down markets. For anything else — elections, sports, any market with two outcomes — bring the data: the same engine, fills, fees and report run on it. ```python from outcometick.backtest import run, Dataset # A directory: markets.json (or .jsonl) at the top, CLOB market-channel frames in *.jsonl / *.jsonl.gz under it. result = run(MyStrategy, "my-polymarket-data") # Or in memory: Gamma market objects (or {market_id, token_ids, labels, ...}) and the frames you recorded. result = run(MyStrategy, Dataset.from_frames(gamma_markets, frames)) # Or the single-book table shape (l2 rows with bid_prices/ask_prices/pc_price..., a trades table): result = run(MyStrategy, Dataset.from_pmdata(l2, trades, market=gamma_market)) ``` - **Frames** are Polymarket's own: `book`, `price_change`, `best_bid_ask`, `last_trade_price`, `market_resolved` — raw, or as rows with the frame under `payload` (the archive's shape). A frame is matched to its market by condition id, slug or token id; every identifier it carries must agree. - **Orders can name an outcome**: `Order(side="Yes", ...)` or `"Lakers"` — any of the market's labels, or UP/DOWN for outcome 0/1. A label the market does not have stops the run. - **Settlement only from evidence.** A market pays $1 / $0 (or 50:50) when Gamma says it resolved (`umaResolutionStatus` and an exact `outcomePrices`), a `market_resolved` frame names the winner, or you give `resolution: {outcome, resolved_ts_ms}` — and every source agrees. Trading ends the millisecond before the outcome is public; nothing after it reaches your strategy, and the payout lands at that time. - **Unresolved markets are valued, not guessed.** When trading ends, open positions are valued at the external mid (UP book, else the mirror of DOWN's, else the last public print), computed from the market data alone — your own orders cannot move it. `mark_max_age_ms` refuses an old quote; a position with no usable mark is valued at 0. The report separates `realised_pnl` from `marked_pnl` and says it is a valuation, not an exit. - **Strict.** A book row that cannot be read, or that names a token the market does not own, drops the whole market (a partial book fills at prices that no longer exist); the report lists it with the reason. ## How a run works ``` archive (gzip JSONL / CSV, as published) │ decode: markets → each market's settlement stream, book, deltas, trades; │ the book stream thinned to one cadence per asset; same-millisecond order fixed ▼ one event stream per market (session mode: one stream across markets, by time) │ pulled one event at a time ▼ engine: order book (UP + DOWN) ── your strategy's hooks ── orders (IOC or resting, latency, limit) │ taker fills walk the depth · resting orders queue on both tokens' prints │ venue taker fee · settlement $1 / $0 at the official outcome ▼ report: PnL, Brier, calibration, baselines, slippage, maker metrics, fee model, coverage ``` ## Limits - Resting orders: queue position is an estimate from aggregated book levels (individual maker orders are not in the feed), book deltas are thinned to a capture cadence, and your orders have no market impact. They run on Polymarket only: the Predict.fun archive has no trade stream to advance a queue, so `gtc` is refused there. The independent auditor does not yet re-derive resting-order fills. - From the archive: 5-minute and 15-minute crypto Up/Down markets. Any other Polymarket binary market runs from your own data ([above](#your-own-data-any-polymarket-binary-market)); the hosted backtester runs archive data only. - The Binance reference feed lives on the hosted runner, not in the archive, so strategies that read it run there. Your own CSV series (`series`, read with `ctx.ext()`) run with `ot run` and hosted; this runner refuses them for now rather than replaying without them. - One sample day says nothing about next month. Backtest over a range before trusting any number. ## On outcometick.com - **The data behind it.** A [tick-level archive of Polymarket and Predict.fun](https://outcometick.com) crypto Up/Down markets: order-book snapshots and deltas, trades, best bid/ask, market metadata, and the Chainlink streams the markets settle on, published daily. Download a day and point `run()` at it, or read the files with pandas, Polars or DuckDB. The free sample is also on [GitHub](https://github.com/outcometick/polymarket-tick-data-samples), Kaggle and Hugging Face. - **The hosted backtester.** [outcometick.com/backtest](https://outcometick.com/backtest) runs the same strategy class over recent weeks on our machines, with nothing to download — paid per market-day scanned, and a run that produces no report costs nothing. - **Settlement audit.** [Market by market](https://outcometick.com/polymarket-settlement-audit), the outcome recomputed from the archived Chainlink stream and compared with the venue's. - **From an AI coding tool.** The npm package `outcometick` includes an MCP server (`ot mcp`) so Claude Code or Cursor can search the archive and check a strategy locally — see [the docs](https://outcometick.com/docs). - 中文站:[outcometick.com/zh](https://outcometick.com/zh) ## Community Questions, strategy ideas, or a backtest result to compare notes on: - Discord (English and 中文): https://discord.gg/ZDMqkptwm5 - Telegram: https://t.me/+TcK_hJOzOz5mZTk1 ## This repository | path | what | | --- | --- | | `outcometick/` | the `outcometick` PyPI package: SDK, data client (`outcometick.data`), `outcometick.backtest` | | `tests/` | the package's unit tests, including answers worked by hand | | `examples/` | runnable strategies; `examples/templates/` holds the templates `ot init` ships | | `notebooks/` | the quick start as a notebook, with its output | | `verify/` | the tests that compare this package with the JavaScript implementation | | `docs/` | methodology and verification | Generated from the outcometick monorepo, where the same engine runs the hosted backtester; the PyPI release comes from [outcometick-sdk-python](https://github.com/outcometick/outcometick-sdk-python). Issues and pull requests are welcome here. License: MIT. --- # Methodology How a backtest turns archived files into a report, rule by rule. Every rule here is implemented once in each language and the two are tested against each other (see [verification](verification.md)); where a rule is an approximation, it says so. ## 1. From files to events The archive is gzip-compressed JSONL and CSV, one file per venue × dataset × asset group × UTC day, exactly as a subscriber downloads it. **Markets come first.** Each day's `markets` rows are normalised to one record per market: id, asset, interval, opening and closing time, strike, official outcome, token ids and — for Polymarket — the fee schedule. Everything else is attached to a market by its slug (Polymarket) or numeric id (Predict.fun). **Which settlement stream.** A Polymarket crypto market settles on the instantaneous Chainlink stream, a 30-second TWAP or a 60-second TWAP. The market's own config states which (`cryptoMarketConfig.twapLookbackSeconds`: null or 0 → instantaneous, 30, 60). The venue moved markets between these over time, so the date is never used to decide. A Predict.fun market settles on its own price feed, matched by `price_feed_id`. **A market is dropped, and listed in the report's coverage, when** it has no asset; it is a Polymarket market whose `token_ids` is not two distinct ids (every book, best-bid/ask and trade row is assigned to Up or Down through that pair, so without it the market would replay against an empty book); its settlement stream cannot be resolved; its outcome cannot be read (Polymarket: a complete `["1","0"]` / `["0","1"]` pair from a resolved market; Predict.fun: an `end_price` exists); it has no events inside its window; or its window has no settlement ticks. A dropped market is visible and costs nothing; a market settled on a guess would be invisible and wrong. **The window.** A market's events are cut to `[open, close]`. A settlement file is a whole day, so this is what keeps 14:00 prices out of a market that closed at 10:00. **Order within one millisecond** is fixed, never left to read order: the book first, then trades, then settlement ticks; within the book, a full snapshot before a delta. A hook therefore sees the market as it already was at that instant, never as it is about to be. ## 2. The order book Two books per market: UP and DOWN, each with bids and asks. - **Polymarket** publishes one side per snapshot row (`book`, by `asset_id`); a row replaces that token's ladder and leaves the other token's alone. `price_change` sets the absolute size of one level (size 0 deletes it); its `BUY` rows are bids and `SELL` rows are asks. - **Predict.fun** publishes one book per market. It is UP's book; DOWN is its mirror (a bid at 0.40 for UP is an ask at 0.60 for DOWN). - Prices are compared on a 1e-4 grid, so a float such as `0.1 + 0.2` cannot decide whether an order fills. - **Capture cadence.** The book stream was archived at a set cadence per asset (one row per market per window, keeping the first). A run replays each asset at the coarsest cadence its date range contains, so the same book never changes density part-way through a report. - **Best bid/ask as a bound.** The unthrottled `best_bid_ask` stream carries prices without sizes. It is applied as a deletion only: levels strictly better than the latest best price, and older than it, are removed. It never adds a level, because a level without a size would be invented liquidity. It does not reach a hook. It is on whenever `book` is declared: the `bbo` dataset comes with it. What this cannot restore: between snapshots, a level added or resized in a delta that the cadence dropped is missing or stale until the next snapshot. That applies at the top of the book as well as below it. How much that is, measured against the venue's full stream over 24 hours of live Polymarket crypto markets (2026-10-11 to 2026-10-12, replayed in backtest order): the best price rebuilt from the archive's book differed from the venue's at 50.6% of sampled moments, and at 36.5% with `best_bid_ask` applied. Most of what remains is a better level added in a dropped delta, which a price-only stream cannot add back. The report's `book_age` block (section 7) shows how much of a run's taker size came from levels last set more than a second before the fill. ## 3. Orders and fills A hook returns an `Order` (or a list, or nothing): | field | meaning | | --- | --- | | `side` | `"UP"` or `"DOWN"` — which token | | `size` | contracts; or `notional` (dollars) with a `limit`, giving `floor(notional / limit)` contracts (0 contracts raises: skip an order whose budget is below the limit price) | | `limit` | a ceiling when buying, a floor when selling; levels beyond it are not taken | | `reduce_only` | sell into the bids of a token you hold, at most what you hold | | `hold_s` | flatten this many seconds after the fill | | `tif` | `"ioc"` (the default), or `"gtc"` for a resting order (Polymarket; below) | **Matching** walks the book from the best level outward and consumes what it takes, so two orders in one event cannot fill against the same depth. What the book does not have is reported as unfilled. The fill row records the price on the screen when the order was sent (`quoted_px`), the average and worst price paid, and the levels walked — the slippage panel is built from these. **Latency.** With `latency_ms`, an order decided at `t` is matched at `t + latency`. Anything scheduled strictly before an event resolves against the book as it stood before that event; anything scheduled at the event's own millisecond resolves after the event has been applied. An order still pending when the market closes did not land. **Resting orders** (`tif: "gtc"`, Polymarket only) are filled by a queue model, `polymarket-queue-v1`. A resting order's fill depends on its place in the queue, which no public feed records, and an optimistic guess inflates market-making returns by multiples — so the model is built from what the archive does record, and every ambiguity is resolved against the strategy. *What the archive says, measured.* On the public sample day (BTC 5-minute, 2026-09-08), joined against the venue's own per-match trade records: - The book is **mirrored**. Every `price_change` carries a pair: a bid for UP at q and an ask for DOWN at 1 − q, same size. They are one queue, published twice. Every own order is therefore placed in one canonical queue, UP's view: buying DOWN at q sits with the UP asks at 1 − q, selling DOWN at q with the UP bids at 1 − q. - A trade **prints once, on the taker's token**: 480,491 prints, 480,491 distinct transaction hashes, and in a market joined trade by trade, 1,138 of 1,138 prints carried the taker's token, the taker's direction and the taker's total size. The price is a maker price — of a multi-level sweep, sometimes the best and sometimes the worst. Makers sit on either token: in that market 555 of the 1,138 prints were pure mints (a DOWN buyer filled by resting UP bids). A resting UP bid is consumed by UP sell prints at its price **and** DOWN buy prints at 1 − its price. - Trades and the top of book are stored in full; book deltas are thinned per market (dropped, not merged — each kept delta still states the true size of its level at that instant); snapshots are thinned per token. - A print can **trail the book update it caused**. For about a quarter of taker-buy prints the full-rate top of book already showed the level gone before the print's own timestamp (99.9th percentile of that lead: 702 ms for taker buys, 639 ms for taker sells, on observed transitions). *The rules.* 1. **Join.** An order that reaches the venue (after `latency`) takes liquidity first if it is marketable on its own token, exactly like an IOC (or is refused, with `post_only`); the remainder rests behind the size visible at its price on the canonical ladder. Own orders at the same price queue in arrival order; two own orders that would cross each other are refused. 2. **Reach.** A print fills only orders at prices at least as good as its own price. The print's price may be the best of a sweep, so nothing beyond it is assumed reached. 3. **One volume budget per print.** Walking the reached prices best first, the print's size pays, in order: depth the book removed within the lag window before the print, at that depth's own price (the depletion ledger — the print that explains a book update must not be able to reach past the volume that update removed); the external queue in front of each own order; the order itself; the rest of the level. Own fills plus everything else never exceed the size that printed. 4. **Book updates shorten, never fill.** After any book event an order's queue estimate is capped at the visible size of its level — whatever was in front of it and still rests is visible. A missed add in a thinned delta stream can make a level look shorter than it was; that is the one direction this rule can err, and it is disclosed. A book that crosses a resting order does not fill it (an absolute size is not arriving aggressive volume); such crossings are counted in the report. 5. **Lag window, both ways** (`print_lag_ms`, 800 ms: the measured 99.9th percentile above, rounded up). A new order ignores every print stamped within the window after it rests — that print may describe a match from before it existed. A cancelled order stays exposed for the window after the cancel lands (`cancel_latency`) — a print in it may describe a match from before the cancel. The second rule is a stress assumption and is labelled one. 6. **Close.** Every order still working at the close expires before settlement, including ones that had not reached the venue yet. Nothing fills after the close. Fills are at the order's own limit. The model is the same code in both engines and the conformance suite compares them fill for fill, including queue and markout fields. The report's `maker` block carries the counters (rested, filled, cancelled, expired, refused, volume held back by the lag window, book crossings), fill rate by size and by order, median queue ahead at entry and at fill, median time in queue, and markouts: the mid of the fill token 1 s, 10 s and 60 s after each fill (from the latest book at or before that instant, null if that book is more than 5 s old or the horizon passes the close), and to settlement, all per share and signed for the holder. The independent auditor does not yet re-derive resting-order fills and refuses such reports with its own exit code rather than passing them. ## 4. Settlement At the market's close, every open position settles at the official outcome: $1 a contract on the winning side, $0 on the losing side, $0.50 on both sides of a Predict.fun tie. Settlement charges no fee. ## 5. Fees The run's fee policy is one of: - **venue** (default) — each market's own schedule. Polymarket's crypto markets charge the taker `C × rate × p × (1 − p)` in USDC, rounded to five decimals, and charge makers nothing; the archive carries each market's `feeSchedule` (on the 2026-09-08 sample: `rate 0.07, exponent 1`). Taker fills pay it; resting-order (maker) fills pay nothing, and the venue's maker rebate — paid from a pool, not per fill — is not modelled. - **bps** — a flat rate on notional for every fill, taker or maker, set with `fee_bps` (including 0). Details that the report's `fee_model` block states: - *Per level, an estimate.* The venue applies the formula per match — one taker order against one maker order — and rounds each. The archive keeps book levels, and a level aggregates maker orders whose sizes are gone, so the fee is computed and rounded (half up) per level taken. - *Known zero vs unknown.* A market that says fees are off is charged nothing and counted as a known zero. A market whose schedule we cannot read, or whose curve is not the documented one (`exponent ≠ 1`), is charged nothing and counted as unknown. The Predict.fun archive carries no fee schedule, so a Predict.fun run is fee-free unless you set `fee_bps`. - *Maker rebates* are not part of the venue policy: the venue pays them from a pool, not per fill. Setting `maker_rebate` (section 10) credits an estimate. Fees are in every trade's PnL and in net PnL. Edge per contract, the Brier score, calibration and the naive baselines are price measures and are computed before fees. ## 6. No look-ahead - Events are handed to the strategy one at a time from a stream; the rest of the day is not in the process. - A market's `outcome` is removed from what `on_market_open` and every event hook can see. Only `on_settle` receives it. - `ctx.book()` is the book as of the current event; `ctx.history()` holds settlement ticks already seen. - **market mode** (default): each market gets a fresh strategy instance, so nothing carries across markets. - **session mode**: one instance and one portfolio for the whole run. Markets that overlap in time are replayed interleaved, in one global time order (an opening first at a given millisecond, then events in market order, then closings). Before each event, every other open market first settles what it had scheduled strictly earlier. State carried from one market to another can therefore never be ahead of the clock. ## 7. The report | block | what it holds | | --- | --- | | `metrics` | net PnL, win rate, profit factor, max drawdown (on realised PnL), Sharpe and Sortino (daily realised PnL, ×√365; Sortino against the downside, target 0), net PnL over max drawdown, trading days / winning days / best and worst day, peak capital at risk, return on it, holding ratio, edge per contract (share-weighted) and edge per trade (each settled trade counts once — the two can have opposite signs), Brier score, fees, average hold | | `by_settlement` | the same results per settlement rule (the stream each market settles on: instantaneous, 30 s or 60 s TWAP), with every market counted, entered or not — markets on different rules are reported side by side, never only blended | | `activity` | contracts and dollars that changed hands, taker and maker, from the fill log | | `fingerprint` | the pre-registration fingerprint (below) | | `calibration` | by entry-price bucket: price paid against the rate at which that side actually won | | `baselines` | always UP, always DOWN, always the favourite — bought at the opening quote (the first moment both sides are quoted), at the strategy's average size, held to settlement | | `slippage` | fills at the quote, fills that walked the book, unfilled orders, slippage percentiles, PnL lost to slippage | | `fee_model` | the fee policy, model, rounding assumption, and markets charged / known zero / unknown | | `coverage` | market-days scanned, every dropped market-day with its reason, which settlement stream each market used, which days the best-bid/ask refinement applied to | | `book_age` | taker size by the age of the book level it took: fill time minus when that level was last set by a snapshot or delta (archive markets only) | | `equity` | realised PnL, one point per closed trade (thinned to 2,000 points, keeping every extreme) | Full trade and fill rows are in `Result.trades` / `Result.fills` (and in the hosted run's archive). `Result.markets` (`markets.csv` in an archive) has one row per market replayed **including every market the strategy never entered** — market id, asset, length, settlement rule, open and close, strike, outcome, opening quotes, whether it was entered, trades, contracts, PnL, fees, first entry and both edges — which is what a market-level confidence interval or a permutation test on outcomes needs. **Pre-registration.** `report.fingerprint.sha256` is one hash over every file of the strategy directory byte for byte (code, a config file it reads, its series and `outcometick.json` itself — `code_sha256`; reformatting the manifest is a different fingerprint, because the strategy could read the difference), the manifest's defaults spelled out, the params, the execution options, the bytes of every declared series, the scope (venue, assets, and every day run, in order — dropping one day from the middle is a different fingerprint), your own data's bytes (generic data: the markets file and every frame file, `data_sha256`), the local replay settings (`book_throttle`, `mark_max_age_ms`), the fee policy actually applied, the implementation (engine image tag and decoder version — an upgrade that changes results changes the fingerprint) and the seed. Compute it before running — `ot fingerprint