# 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 --assets … --from … --to …` (or `--data `), or `outcometick.backtest.fingerprint(path, assets=…, days=[…])` — and publish it; the report of the run you then make carries the same value, from the hosted queue, `ot run` or the Python runner alike. A strategy passed as an in-process Python class has no code files, so its fingerprint covers everything except the code (`code_sha256` is null). The archive files a run actually read are reported next to it as `inputs_sha256` (sorted `path:sha256` lines, hashed): it cannot be part of the pre-registered value, because which files a day needs is known only once its markets are read, but it shows exactly which bytes produced the report — the hosted catalog's checksums and a local archive's files give the same value for the same files. `run()` and `run_template()` execute the strategy inside your Python process: the files are hashed and loaded from one snapshot, bytecode caches and earlier strategy modules are set aside, and a directory that changes during the run is refused — but process-wide state (a library the strategy monkeypatches, globals left by earlier code) is shared and not covered by the fingerprint. For a fully isolated, reproducible run use `ot run` (a fresh process per run) or the hosted backtester (a container). ## 8. Limits that apply everywhere - The per-event budget the hosted sandbox enforces (a sustained mean of 400 µs per hook call) applies locally too, so a strategy that would be refused there is refused here. - One day is a sample, not evidence. Backtest across a range, and compare against the baselines. ## 9. Your own data (any Polymarket binary market) The same engine, fills, fees and report, on data you bring: a markets file (Gamma market objects, or `{market_id, token_ids, labels, ...}`) and Polymarket CLOB market-channel frames. What differs from the archive, and why: - **Order within a millisecond is the input's.** Events sort by time, then by input position (file, line, position in a `price_changes` batch). The archive sorts a millisecond by kind because its files are read in an arbitrary order; a recorded stream is not. For the same reason a best-bid/ask bound removes a level set in the same millisecond before it. - **Strict parsing.** A book, delta or bound row that cannot be read — a price outside [0, 1], a side other than BUY/SELL, two levels at one price, a token the market does not own, identifiers naming two markets — drops the whole market, listed with the reason and the input position. A bad trade row is dropped and counted. - **A window that opens mid-stream** starts from the book as it stood: every earlier book row is folded into one snapshot at the window's start. - **Resolution only from evidence**: Gamma's `umaResolutionStatus: resolved` with `outcomePrices` exactly 1/0, 0/1 or 0.5/0.5 (Polymarket's 50:50), a `market_resolved` frame, or a `resolution` you supply. Sources must agree, or the market is dropped; a resolution without a time is dropped too, because it cannot say when trading had to stop. The time used is the frame's when there is one (observed), else yours, else Gamma's. - **Lifecycle.** Trading ends at the first of: your declared trading end, the millisecond before the outcome became public, the last event. Events after it are dropped before the replay; orders still in flight expire; resting orders expire. A resolved market pays at its resolution time (in session mode, cash arrives then). - **Valuation.** An unresolved market's open positions are valued when trading ends at a mark computed from the market data only — the UP token's mid, else 1 − the DOWN token's mid, else the last public print — each candidate checked against `mark_max_age_ms` before the next is tried. A hedged pair is worth exactly $1. Cash does not move: the report's `valuation` block separates `realised_pnl` from `marked_pnl`, lists every position valued at 0 for want of a mark, and states the oldest mark used. These rows are not scored for Brier, edge or calibration (only held-to-settlement rows are), and the baselines use resolved markets only, with the strategy's own result on those markets (`strategy_on_resolved`) beside them. - **The single-book table shape** (Python, `Dataset.from_pmdata`) carries one book; the other outcome is its mirror, and each update moves both in one step. Trades come from the trades table only, so each public print is counted once. - **Known simplification**: on any mirrored book (this one, and Predict.fun's), an immediate order on each of the two outcomes consumes its own copy of the depth, so the same source liquidity can be taken twice. Resting orders already share one queue and one print budget. ## 10. Execution options All off by default — a run that sets none of them replays exactly as before. Same keys in the manifest, in `run(...)` / `run_template(...)`, hosted and local; both engines implement each one and the conformance suite compares them trade by trade. - **`fok: true`** enables `tif: "fok"`: the whole size must be available within the limit when the order reaches the book (measured before anything is taken), or nothing fills. A refused order is a fill row with `reject_reason: "fok_unfilled"`. Declared up front so every output knows its columns before the first row. - **`order_rules`** holds orders to the venue's rules: the size is at least the minimum and on the 0.01-share grid, the limit is on the tick grid, and a notional converts to hundredths of a share (an SDK `Order` built with a notional converts to whole shares when it is built — still on the grid; a plain order object's notional is converted by the runner). An exit is clamped to the position and floored to the grid before it is checked, so a dust position below the minimum is held to settlement, as it would be on the venue. `"venue"` reads each market's own tick and minimum (your own data); `"assumed"` is for the archive, whose markets do not carry them: tick 0.001 and minimum 5 shares, the values measured on the archive. The report says it is an assumption — a market quoting in 0.01 would refuse a 0.001 limit accepted here, one quoting finer would accept limits refused here. Refusals are counted by reason (`below_min_size`, `size_step`, `off_tick`). - **`maker_rebate`** (a fraction, e.g. 0.2) credits each resting fill with that share of the taker fee the same fill would have cost, as a negative fee. An estimate: the venue pays maker rebates from a daily pool, pro rata, and the report says so. - **`quote_guard`** — `{max_age_ms, ask_sum: [lo, hi]}` — lets an opening taker order execute only when both outcomes have a best ask and their sum is in range, and all four best quotes were set within `max_age_ms` (a missing quote fails). A resting order that fails it is refused whole, so it cannot rest and fill later. Exits are not blocked unless the guard sets `include_exits: true` — a guard that blocks an exit holds the position to settlement. Ages are exchange-time ages of the level (`ctx.book().quote_ts(side)`). - **`mirror_depth: "shared"`** is for single-book markets (Predict.fun, and data read with `from_pmdata`): an outcome's ask at p and the other's bid at 1 − p are one liquidity, so taking one removes the other. - **Local only:** `book_throttle=False` (`ot run --book-throttle off`) replays the archive's book stream without the per-asset thinning the hosted runs apply. Passing it either way writes `coverage.book_throttle`, the window actually used per asset or `"off"`. The archive's stream was already rate-limited when it was captured, so "off" is the densest the archive has, not every update the venue sent. The report's `orders` block lists the options used, what they refused and the rebate total. ## 11. Timers and feed latency (one time line) Declaring the `on_timer` hook or a `feed_latency` puts the run on a single time line across every market in it. Each market's pending work — orders and cancels reaching the venue, timers, lagged events reaching the strategy — is a job at a time; before anything at time T is processed, every job strictly earlier runs, in every market, earliest first (at one millisecond: exchange jobs, then arrivals, then timers; then the market's position in the run; then the order they were made). Jobs due exactly at T run after everything the data says happened at T. So a strategy shared across overlapping markets never sees its clock go back, and a timer at 150 never sees a reference row stamped 151. - **Timers.** `ctx.schedule(at_ms, tag)` wakes `on_timer(ctx, timer)` at that time, with `ctx.now = at_ms`; it may return orders like any hook. `at_ms` must be after now and not after the market's declared close; a market opens at its own open time, so `on_market_open` can schedule from there. Limits per market: 64 pending, 10,000 set, 100,000 fired. Timers still pending when trading ends are dropped and counted (`report.timers`). - **A ctx kept from another market** (session mode) acts at the run's time, not that market's last event: a timer it sets must be after the run's current time, and a cancel it sends happens from then on. - **Feed latency.** With `feed_latency: L`, every event reaches the strategy L ms after it happened: hooks fire then, and `ctx.book()`, `ctx.history()` and the position's mark show the market as the strategy has heard of it. Orders, resting-order queues and the quote guard always use the venue's own book at the moment they act. Fills are known to `ctx.position()` when they happen — there is no separate order-response latency. - Without either, runs take the per-market path they always took, byte for byte. ## 12. Probabilistic queue models (optional) Rule 4 of section 3 assumes the worst about a level that shrinks with no trade to explain it: everything that left was behind your order. `queue_model` replaces that with an estimate of how much left from in front, in the shape hftbacktest popularised: with `a` in front of the order and `b` behind it, the share from behind is | `prob` | share from behind | |---|---| | `power` (`n` 1–10) | `b^n / (b^n + a^n)` | | `power2` (`n` 1–10) | `(b / (a + b))^n` | | `power3` (`n` 1–10) | `1 − (a / (a + b))^n` | | `log` | `ln(1+b) / (ln(1+b) + ln(1+a))` | | `log2` | `ln(1+b) / ln(1+a+b)` | and an unexplained drop `c` moves the order to `a − (1 − p)·c + min(0, b − p·c)`, never past the visible size. What counts as *unexplained* is decided on the archive alone, where your orders do not exist. A print removes volume from the levels it reaches (consuming first any book drop it explains, the depletion ledger); what it removed and the book has not shown yet waits, per price, for the next drop there, which absorbs it once. Only the remainder of a drop is ledgered and handed to the model. A bbo bound only caps the queue (a pruned crossed level says nothing about where in it volume left). Each order keeps its own history on top: the waterfall of section 3 advances it, the entry window still applies, so an order's queue can lag what the archive level knows — that only ever errs against it. Limits, stated plainly: both waiting lists live `print_lag_ms` (800 ms), a heuristic window — a book drop arriving after the print's entry expired is read as a cancellation; a print masked by a same-size refill within one book update waits until it expires. The model's shares are an assumption the archive cannot check. The default stays the conservative queue; run both and compare. The report names the model (`maker.queue_model` `polymarket-queue-v1+prob-v1`, `maker.queue_prob`). The functions use only `+ − × ÷` in a fixed order (no library `pow` or `log`), so both engines produce the same bits, and the conformance suite checks them bit for bit. ## 13. The recorder (mark-to-market on a time grid) `record_ms: N` (market mode, 1000–3,600,000) writes each market's value every N milliseconds of UTC time: the cash the market has cost or paid so far, plus every open leg at its token's best bid (depth ignored; a leg with no bid counts 0 — what selling now would fetch, never a mid or a cost). One rule makes each point exact: before any change at time t — an order or cancel reaching the venue, an event, a timer, a terminal mark, the settlement — every grid point before t is written, so a point is the state after everything at or before it and nothing later; after the settlement, one final sample. Recording reads the ledger and the book only and changes no fill (tested off/on in both engines). The report's `record` block sums every market on the grid (a market counts 0 before its first point and its final value after it): points, final value, the maximum drawdown of that account value (peak starting at 0) and when, the worst point, the largest and the mean open-position value. It sees what the realised-PnL drawdown in `metrics` cannot — a loss an open position carried before it closed. `record.csv` has the account series and `record_markets.csv` every market's samples (`Result.record` / `record_markets` in Python). A run that would write more than 1,000,000 rows or 256 MiB of samples stops recording and reports `truncated` with no series — never a partial curve; the hosted backtester refuses such a scope at submit and names the smallest `record_ms` that fits. ## 14. Spot and the locked part of the settlement TWAP (optional) Polymarket's 5- and 15-minute crypto markets settle on Chainlink's 60-second TWAP at the close (`raw.cryptoMarketConfig.twapLookbackSeconds`). In the last minute part of that average is already fixed, and a strategy may want to know how much and at what level. **The `spot` dataset.** Declaring it feeds each market the 1 Hz Chainlink report stream of its asset (the archive's `prices` tree) inside the market's window, whatever stream the market settles on. It is quiet: it wakes no hook, is not a tick, never enters `ctx.history()` or the rolling helpers, and is not counted as an event seen. Within a millisecond a spot value is applied before the book, trades and ticks, so a hook on that millisecond reads it (a tie-break, not a recorded order). Under a feed latency it reaches the strategy late like every other event. Polymarket only; outside its capture window a run is refused. The report's `spot` block counts the markets that got any spot value and those that got none. Both read only values stamped at or before `ctx.now`, like `ctx.ref()`: a ctx kept from another market in session mode answers for its own clock, not a mix of later values and an earlier time. **`ctx.spot()`** returns the latest spot value that has reached the strategy, `{value, ts_ms}`, or null. **`ctx.twap_locked()`** reads the window `[close - lookback, close)` (lookback from the market's settlement stream: 60 s for `twap60s`, 30 s for `twap30s`; null for a market that does not settle on a TWAP, before the window opens, or before any spot value in it has arrived): - `value`: the mean of the window's spot samples seen so far, one per feed timestamp (the first to arrive), no interpolation over gaps; - `samples`; `elapsed_ms` since the window opened (capped at the lookback); `elapsed_fraction` = elapsed_ms / lookback — time elapsed, not certainty; - `coverage` = samples / elapsed whole seconds, capped at 1 — the archive misses some seconds; - `last_ts_ms`: feed time of the latest spot value seen; - `projected` = elapsed_fraction × value + (1 − elapsed_fraction) × latest spot: the final average *if the price stayed where it is*. Not a forecast. null when coverage is below 0.8 or the latest spot value is more than 5 s old. **It is an approximation.** Chainlink averages finer observations than this per-second stream. On one BTC day we measured (6,675 complete windows), recomputing the official twap60s from the archived spot stream over `[t - 60 s, t)` matched exactly never; the error was $0.51 at the median, $3.45 at the 99th percentile and $6.35 at worst. Settlement in the backtest never uses this: markets settle on their official outcome (§4). Event times are the feed's own (`feed_ts_ms`), as for every tick; set `feed_latency` to model when you would have heard of a value. --- # How we verify it A backtest is only worth what you can check about it. The engine exists twice — JavaScript (the CLI, the hosted queue, Node strategies) and Python (Python strategies, locally and in the sandbox) — and the archive decoder and the report exist twice — JavaScript (the CLI and the queue) and Python (`outcometick.backtest`). Two implementations of the same rules drift, silently, unless something compares them. These are the things that do. ## Engine against engine `runner/conformance/` (in [polymarket-backtest-js](https://github.com/outcometick/polymarket-backtest-js)) runs both harnesses as real subprocesses over identical inputs and compares every trade and fill field: delayed orders, hold expiry, partial reductions, unusable orders, one-sided snapshots, best-bid/ask pruning, budget and log limits, session mode across overlapping markets, and the venue fee in each of its states. Expected values for the fee cases are worked by hand, not taken from either engine — agreement alone would pass two engines that are wrong the same way. ## Decoder and report against decoder and report `verify/equivalence.test.mjs` (in [polymarket-backtest](https://github.com/outcometick/polymarket-backtest)) decodes the same archive with both decoders and compares every market and every event. It then runs Python strategies through the command line (the Python harness fed by Node) and through `outcometick.backtest` (the same harness loop fed in-process) and requires every report field — except wall-clock timing — and every trade and fill row to match. Finally it runs the same strategy written in JavaScript and in Python and requires the same trades, fills and logs, byte for byte. It runs on two synthetic archives built to hold what real days rarely do: a market crossing midnight, a market with no settlement ticks, an unresolved one, a Predict.fun tie, fees off and fees unreadable, a market on the 30-second TWAP, five kinds of event on one millisecond, deltas landing exactly on a cadence boundary, an empty file. With `OT_SAMPLE_DIR` pointing at the unpacked public samples it runs on those as well. It caught, on its first CI run, that Python's `os.scandir` returns files in filesystem order where Node's `readdir` sorts them. `verify/js-semantics.test.mjs` checks the JavaScript number rules the Python decoder reproduces — `Number(" 12 ")`, `Number("0x10")`, `(0.125).toFixed(2)`, `Math.round(0.49999999999999994)`, number-to-string past 1e21 — against JavaScript itself. ## Your own data `verify/generic-equivalence.test.mjs` does the same for data you bring (any Polymarket binary market): a synthetic dataset holding every frame form, each resolution source, unresolved markets valued three ways, one market refused per strictness rule, a window opening mid-stream, same-millisecond sequences across markets, a gzipped file, CRLF line ends and junk lines. Both decoders must produce the same markets, refusals, row counts and events; then the same strategies — orders by outcome label, resting quotes, session mode — run through the command line in JavaScript and in Python and through the Python library, and every trade, fill, report field and log line must match. The conformance suite adds generic markets to the engine-against-engine comparison: mirrored deltas, a bound in the same millisecond as the level it removes, trading ending before settlement, valued and unvalued positions. And on the real public sample, an archive day written back out as generic data (frames in the archive's own order) replays to the same fills, and to the same trades except the settlement millisecond. ## Answers worked by hand `tests/test_backtest.py` (Python repository) builds a one-market archive and checks the result against arithmetic done on paper: the fill price across two levels, the fee per level, the payout at settlement, a known-zero fee, a flat override, latency, the outcome hidden until settlement, and that a cached decode replays identically. ## Run them Python repository: ```bash pip install . python -m unittest discover -s tests npm install --no-save outcometick@"$(python -c 'import json; print(json.load(open("verify/version.json"))["version"])')" node --test 'verify/*.test.mjs' # needs Node 24 and python3 OT_SAMPLE_DIR=/path/to/samples node --test 'verify/*.test.mjs' # also on the real sample days ``` JavaScript repository: ```bash npm install && npm test node --test runner/conformance/*.test.mjs # needs python3 ``` ## What is not proven here - That the archive is complete: it is not. Gaps in what was captured are published per day by the data product, and a backtest lists every market-day it could not replay. - That a fee estimated per book level equals what the venue charged per match: see [methodology § 5](methodology.md#5-fees). - That a strategy which worked on these days will work on others. --- # API reference The public Python API, from the docstrings. Strategy-side calls (ctx, events, every Order option): https://outcometick.com/docs/sdk ## run ```python run(strategy, data, venue=None, assets=None, days=None, datasets=None, intervals=None, params=None, mode='market', latency_ms=0, fee_bps=None, seed=1, hooks=None, cancel_latency_ms=None, cache=True, workers=None, mark_max_age_ms=None, fok=False, order_rules=None, maker_rebate=None, quote_guard=None, mirror_depth=None, book_throttle=None, feed_latency=None, queue_model=None, record_ms=None) ``` Backtest `strategy` (a class) over an unpacked archive at `data`, or over your own data for any Polymarket binary market: a `Dataset`, or a directory with markets.json / markets.jsonl and CLOB frame files. Generic data: every market in it is replayed (no assets / days / intervals); datasets are what the frames hold. A resolved market pays at its resolution time; an unresolved one is valued at the external mid (else last public trade) when trading ends; `mark_max_age_ms` refuses older marks (a leg with no usable mark is valued at 0, and the report says so). Execution options (all off by default; same meaning as the manifest keys): ```text fok True: orders may use tif="fok" (all within the limit, or none) order_rules "assumed" (archive Polymarket: tick 0.001, min 5 shares, sizes in 0.01) or "venue" (your own data: each market's own tick/min) maker_rebate share of the taker fee credited on resting fills (an estimate) quote_guard {"max_age_ms": n, "ask_sum": [lo, hi]}: opening taker orders only execute against fresh, consistent quotes mirror_depth "shared": on single-book markets one fill consumes both tokens feed_latency ms between an event on the venue and the strategy seeing it (hooks fire late and ctx.book() lags; orders still match the venue's book). on_timer + ctx.schedule() wake the strategy at chosen times. queue_model resting orders: where unexplained book decreases came from in the queue: {"prob": "power", "n": 2} (power/power2/power3, n 1..10) or {"prob": "log"} (log/log2). None: the conservative default (cancellations behind the order). record_ms the account's value every record_ms milliseconds (1000..3600000), each market's open legs at the best bid: Result.record, report["record"] (market mode only) book_throttle False: replay the archive's book unthinned (local only); True or False also writes coverage.book_throttle ``` ```text assets default BTC, ETH, SOL, XRP (as `ot run`); name others to include them. datasets default: settlement and book (so orders can fill), plus trades when the class has on_trade. "bbo" (the top-of-book bound) comes with "book". intervals default ["5m"]; Polymarket has 5m and 15m. fee_bps None = each market's venue schedule; a number = flat override. cancel_latency_ms how long ctx.cancel() takes to reach the venue (resting orders); None = the same as latency_ms. Resting (gtc) orders need datasets to include "book" and "trades", and Polymarket. cache keep decoded days under ~/.cache/outcometick/decoded (or a path), so the next run over the same days starts at once. False: never. workers None (default): decode on every CPU core, replay in this process; always the single-process result. A number: decode and replay markets on that many processes (market mode). Parallel replay relies on market mode's contract: a strategy keeps its state on the instance. State found on its class (nested classes included) or in params makes the run replay in one process instead. ``` ## run_template ```python run_template(path, data, venue=None, assets=None, days=None, seed=1, fee_bps=None, cache=True, workers=None) ``` Run a strategy directory (outcometick.json + source), like `ot run`. ## sweep ```python sweep(strategy, data, grid, metric='net_pnl', maximize=None, holdout=0.0, top=3, days=None, params=None, cell_workers=None, run_kwargs={}) ``` Run `strategy` once per combination of `grid` values and rank the cells. ```text grid {param: [values, ...]}, layered over `params` (or the class's own). metric a key of report["metrics"]; maximize defaults to True except for drawdown-, loss- and error-type metrics. holdout fraction of the days (the latest ones) kept out of the ranking; the `top` cells are then replayed on them. 0 = rank on everything. cell_workers None (default): one cell after another. A number: that many cells at once on forked processes; the same result (see the module docstring; needs fork, else sequential). Everything else (venue, assets, datasets, latency_ms, workers, ...) is passed to run() for every cell. ``` ## fingerprint ```python fingerprint(path, assets=None, days=None, venue='polymarket', seed=1, fee_bps=None, data=None, book_throttle=None, mark_max_age_ms=None) ``` A strategy directory's pre-registration fingerprint, without running it. The same value `ot fingerprint` prints and the report of the matching run carries (report["fingerprint"]): one sha256 over the code, manifest, params, execution options, scope and seed. Pass the assets and the days list you will run (from = days[0], to = days[-1], as the report's scope); `data`, if given and your own dataset, makes it a generic-data fingerprint. ## load_sample ```python load_sample(venue='polymarket', cache_dir=None, refresh=False) ``` Download (once) and unpack the public sample for a venue; return its root. venue: "polymarket" or "predict". The archive is ~100-300 MB. ## Result A finished backtest: the report plus every trade, fill and log line. ## SweepResult Every cell's metrics, ranked, with the held-out replay of the top cells. ## Dataset Generic data for `run()`: any Polymarket binary market, your own data. Dataset.from_dir("my-data") # markets.json + *.jsonl frames Dataset.from_frames(markets, frames) # the same, in memory Dataset.from_pmdata(l2, trades, market=gamma) # single-book table shape Datasets add up (`a + b`): one run over every market of both. ## BacktestError The run was refused or could not produce a report. `code` is the same rejection code a queued run would report (E_MANIFEST, E_RUNTIME, ...). ## Strategy Base class for submitted strategies. Hooks have no default implementations, so missing declared hooks are rejected. ## Order An order returned by a hook for simulated execution by the runner. limit is a price ceiling when opening and a floor when reducing. --- # outcometick 回测 · Python 用纯 Python 编写的 Polymarket 逐笔回测引擎。选举、体育、加密货币等任意二元市场,只要有两个结果,用自己采集的盘口数据 (Polymarket 原生的 CLOB 帧,或表格)即可回测;也可以先用一天免费的真实归档试跑。策略逐事件回放盘口,按当时的盘口深度逐档成交, 每个市场只在有结果凭据时以 1 美元 / 0 美元结算,每笔成交都扣平台的吃单手续费。可以在自己的电脑或 notebook 中运行,不依赖其他包。 做市策略可以挂限价单:排队模型按 Polymarket 实际的撮合记录方式建立,报告给出成交后的价格走势,便于评估这些成交。 [English](https://outcometick.github.io/polymarket-backtest/) · **简体中文** · [文档站](https://outcometick.github.io/polymarket-backtest/zh/) · [给 AI agent 的说明](https://outcometick.github.io/polymarket-backtest/agents/)(英文) · [方法说明](methodology.zh.md) · [我们怎么验证](verification.zh.md) · JavaScript / TypeScript 版:[polymarket-backtest-js](https://github.com/outcometick/polymarket-backtest-js) > 开源(MIT),由 [outcometick.com](https://outcometick.com/zh) 维护。它另外提供现成的 Polymarket 与 Predict.fun 加密涨跌市场逐笔归档, > 以及在其服务器上运行同一个策略的[托管回测](https://outcometick.com/zh/backtest);使用本项目无需购买这两项服务。[详见下文。](#outcometickcom-上还有什么) ```bash pip install outcometick # Python 3.10+,不需要别的 ``` ```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) if not 0.55 <= price <= 0.95: return None return Order(side=side, notional=50, limit=0.95) result = run(Favourite, load_sample()) # 一天真实归档,只下载一次,不需要 key print(result.summary()) result.plot() # 需要 matplotlib result.trades_df() # 全部成交(需要 pandas) ``` 上面跑的是一天免费归档,不需要自己的数据。想在自己采集的市场上跑同一个类(任意 Polymarket 二元市场),替换数据即可: ```python from outcometick.backtest import run, Dataset result = run(Favourite, "my-polymarket-data") # 你录下的 markets.json + CLOB 帧 result = run(Favourite, Dataset.from_frames(gamma_markets, frames)) # 或者直接从内存传入 ``` 格式和结算规则见下文[用你自己的数据](#用你自己的数据任意-polymarket-二元市场)。 解码一天的数据会用满所有 CPU 核,结果与单进程逐字节相同。传 `workers=8`(或你的核数),各市场也会并行回放,逐笔成交、合计和日志保持不变。 前提是策略按 market 模式的约定将状态放在实例上(如果检测到状态放在类上或参数里,会改用单进程回放)。在 8 核笔记本上, 免费样本日(289 个市场、761 万个事件)用 `workers=8` 第一次跑约 30 秒,之后约 5 秒。解码结果会缓存,之后再跑这一天即可直接开始回放。 [notebooks/quickstart.ipynb](https://github.com/outcometick/polymarket-backtest/blob/main/notebooks/quickstart.ipynb) 是一个带运行结果的完整示例。 ## 回测规则与实现 结算按平台规则,不做近似。市场只在有结果凭据时兑付 1 美元或 0 美元:用自己的数据时,凭据是 Gamma 的结算状态、 `market_resolved` 帧或你提供的结果,各来源的结果必须一致;用归档里的加密市场时,按该市场实际使用的 Chainlink 结算流读取官方结果。结算流可能是瞬时流、 30 秒 TWAP 或 60 秒 TWAP,具体由市场配置决定(`twapLookbackSeconds`),不按日期推断。Predict.fun 平局按 50:50 兑付。 无法读取结果的市场不参与回测,报告会列出这些市场,不推测结算结果。 每个市场两本盘口。UP 和 DOWN 是两个代币,各有自己的买卖档位。买 UP 吃 UP 的卖档,平仓卖进 UP 的买档。 (Predict.fun 每个市场只发布一本盘口,所以它的 DOWN 一侧是 UP 的镜像。) 按当时的盘口深度逐档成交。订单按对应毫秒重建的盘口逐档成交,盘口里没有的数量记为未成交,不会按最优价全部成交。 设置 `latency_ms` 后,订单按它实际到达时的盘口成交,成交仍受限价约束。 按市场费率计算吃单手续费。Polymarket 向吃单方收取 `C × rate × p × (1 − p)`,每个市场的费率表都在归档里,每笔模拟成交都扣手续费。 报告写明用了哪种手续费模型、有多少个市场读不到费率表。手续费取整方式是估算,报告也会注明:平台按每笔撮合的挂单分别取整, 归档里只有档位,所以这里按吃掉的每个档位计算并取整。 挂单按平台真实的撮合方式排队。`Order(..., tif="gtc")` 按限价挂着,排在该价位已可见的量后面。排队模型依据归档中观察到的三项事实建立,不套用通用交易所模型: 盘口互为镜像(UP 在 q 的买单和 DOWN 在 1 − q 的卖单是同一个队列,只是发布了两次);每笔成交只记录一次,且记在吃单方的代币上。因此,DOWN 买家吃掉挂着的 UP 买单(即铸造撮合)时,也会推进 UP 买单的队列; 只看单个代币的模型会漏掉大约一半的消耗。 成交记录可能晚于它引起的盘口变化到达。 因此,只有成交价格触达挂单时,才会触发挂单成交;计算时先扣除成交前不久盘口已移除的数量 (按这些数量原来的价位扣除);新挂单在一个很短的滞后窗口内忽略所有成交;撤单后,在这个窗口内仍有成交风险。下单延迟和撤单延迟分开设置 (`latency_ms`、`cancel_latency_ms`),挂单成交不收手续费(不计返佣),两个引擎逐笔一致。报告还提供做市指标:成交率、入队时和成交时的 前方排队量、排队时长,以及每笔成交后 1 秒 / 10 秒 / 60 秒和到结算的价格走势,用于评估逆向选择的程度。 参数扫描与样本外验证。`sweep(Strategy, data, {"enter_at_s": [30, 60, 120], "px_max": [0.9, 0.95]}, holdout=0.3)` 遍历所有组合,按前段日期的结果排名,再将排名靠前的组合放到未参与排名的后段日期上重放,并排比较。网格搜索总能找到一个「最优」组合, 样本外结果用于判断这种优势能否延续,还是仅来自噪声。`.table()` 给出 DataFrame,`.heatmap(x, y)` 画热力图。 `cell_workers=8` 让 8 个组合在不同进程上同时跑,结果与逐个跑逐字节相同(样本日 16 个组合:逐个跑 377 秒,8 核 77 秒)。 避免前视。事件逐条传给策略,当天剩下的数据不在内存里;市场的 `outcome` 在 `on_settle` 之前不可见。 session 模式下,时间上重叠的市场按同一条时间线交错重放,跨市场保留的状态不会提前访问尚未发生的事件。 和托管回测用同一个引擎。本地重放与托管沙箱运行 Python 策略使用同一份引擎文件;归档解码和报告从命令行与托管队列使用的 JavaScript 版本移植而来,在公开样本和专门构造的边界归档上逐事件、逐笔对照过。详见[我们怎么验证](verification.zh.md)。 为二元市场设计的报告。Brier 分数、按入场价分桶的校准、每份合约的边际、峰值占用资金、滑点面板,以及同样下单规模的朴素基准: 一直买 UP、一直买 DOWN、开盘时一直买热门。报告第一页会列出策略是否跑赢「买热门」基准。 数据限制。盘口由按固定采集频率获取的快照和增量重建;不抽稀的最优买卖价流只能删除已被越过的档位,不能补充缺失档位。 报告会列出每个无法重放的市场日及原因。细节见[方法说明](methodology.zh.md)。 策略使用普通 Python 类,无需特殊写法或编译:`on_market_open`、`on_tick`、`on_book`、`on_trade`、`on_settle`,返回 `Order` 或不返回任何值。 同一个类在这里和托管沙箱里都能跑。 ## 接口 ```python run(StrategyClass, data, # data:load_sample("polymarket" | "predict") 或一个归档目录 assets=["BTC"], # 默认 BTC、ETH、SOL、XRP days=["2026-10-18"], # 默认归档里的所有日期 intervals=["5m", "15m"], # 默认 ["5m"] datasets=["settlement", "book", "trades", "bbo"], mode="market", # 或 "session":整个区间一个实例、一条时间线 latency_ms=250, # 下单到成交的延迟(挂单到达平台也用它) cancel_latency_ms=None, # ctx.cancel() 到达平台的延迟;None:同 latency_ms fee_bps=None, # None:按每个市场的平台费率;数字:统一费率覆盖 params={"enter_at_s": 60}, # 在策略里用 ctx.p 读 workers=None) # None:解码用满所有核、回放单进程;N:解码和回放都用 N 个进程 run_template("path/to/strategy-dir", data) # 含 outcometick.json 的策略目录,和 `ot run` 一样 fingerprint("path/to/strategy-dir", assets=["BTC"], days=days) # 预注册:跑之前先公布这个 sha256 sweep(StrategyClass, data, {"enter_at_s": [30, 60, 120]}, # 每个组合都是一次普通的 run metric="net_pnl", holdout=0.3, top=3, # 用前 70% 的日子排名,前 3 名在剩下的日子上重放 cell_workers=None) # N:同时跑 N 个组合,结果不变 ``` `Result` 有 `.report`(和托管回测返回的是同一份 JSON)、`.trades`、`.fills`、`.markets`(回放过的每个市场, 无论是否入场都有一行)、`.logs`、`.summary()`、`.plot()`、`.trades_df()`、`.fills_df()`、`.markets_df()`。 `report["fingerprint"]` 就是运行前 `fingerprint()` 计算的 sha256(Methodology §7)。这些调用在你的 Python 进程里运行策略; 需要在完全隔离的环境中复现时,请用 `ot run` 或托管回测。 下面演示挂单及对应的评估指标: ```python class Quote(Strategy): def on_book(self, ctx, ev): bid = ctx.book().best_bid("UP") if bid is None or ctx.orders(): # 同一时间只挂一张 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"] # 成交率、入队/成交时前方排队量、1s / 10s / 60s / 结算的价格走势 ``` `ctx.orders()` 列出你还在挂的单(不提供排队位置,真实平台也不提供);`ctx.cancel(id)` 按 id 或 `client_id` 撤单。 [examples/maker_quotes.py](https://github.com/outcometick/polymarket-backtest/blob/main/examples/maker_quotes.py) 是双边报价的完整例子。 ## 用你自己的数据:任意 Polymarket 二元市场 归档里只有加密货币涨跌市场。其他市场(选举、体育、任何两个结果的市场)可以自带数据,用同一个引擎、同样的成交、 手续费和报告来回测。 ```python from outcometick.backtest import run, Dataset # 目录:顶层放 markets.json(或 .jsonl),下面任意位置放 *.jsonl / *.jsonl.gz 的 CLOB 行情帧。 result = run(MyStrategy, "my-polymarket-data") # 内存里:Gamma 市场对象(或 {market_id, token_ids, labels, ...})加上你录下的帧。 result = run(MyStrategy, Dataset.from_frames(gamma_markets, frames)) # 单盘口表格形态(带 bid_prices/ask_prices/pc_price... 的 l2 行,加一张成交表): result = run(MyStrategy, Dataset.from_pmdata(l2, trades, market=gamma_market)) ``` - 行情帧使用 Polymarket 原生格式:`book`、`price_change`、`best_bid_ask`、`last_trade_price`、`market_resolved`, 可以保持原样,也可以放在 `payload` 下(归档的行格式)。按 condition id、slug 或 token id 对应到市场,帧里带的每个标识都必须一致。 - 下单可以写结果名:`Order(side="Yes", ...)` 或 `"Lakers"`,市场的任一标签;UP/DOWN 对应第 0/1 个结果。 使用市场中不存在的标签,回测立即停止。 - 只凭证据结算。Gamma 显示已结算(`umaResolutionStatus` 且 `outcomePrices` 恰为 1/0、0/1 或 0.5/0.5)、 `market_resolved` 帧给出赢家、或你提供 `resolution: {outcome, resolved_ts_ms}`,且所有来源一致,才按 $1/$0(或 各半)兑付。结果公开的前一毫秒停止交易,之后的行情不会到达策略,兑付记在那个时刻。 - 未结算的市场按估值,不猜结果。交易结束时,未平仓位按外部行情的中间价估值(UP 盘口,否则 DOWN 盘口的镜像, 否则最后一笔公开成交),只用市场数据计算,策略自己的订单不影响估值。`mark_max_age_ms` 拒绝过旧的报价;没有可用 估值的仓位按 0 计。报告把 `realised_pnl` 与 `marked_pnl` 分开,并注明估值不代表可按该价格平仓。 - 数据校验:无法读取的盘口行或指向其他市场 token 的行,会让整个市场不参与回放(残缺的盘口会在已经不存在的价格上 成交),报告里列出原因。 ## 执行选项 默认全部关闭,`run(...)` 的参数与清单字段同名:`fok=True`(全成或全撤)、`order_rules="assumed"`(归档:价格网格 0.001、 最少 5 股、数量以 0.01 股为单位)或 `"venue"`(自己的数据)、`maker_rebate=0.2`(挂单返佣估算)、 `quote_guard={"max_age_ms": 1500, "ask_sum": [0.92, 1.08]}`、`mirror_depth="shared"`(单盘口市场),以及仅限本地使用的 `book_throttle=False`(不抽稀回放归档盘口,报告写明实际用的抽稀档位)。细节见 [方法说明 §10](methodology.zh.md#10-执行选项)。 定义 `on_timer` 并调用 `ctx.schedule(at_ms, tag)`,或设置 `feed_latency=50`,同一次运行的所有市场共用一条时间线:定时器在指定时刻唤醒策略, 行情延迟决定策略收到事件的延后时间,而订单仍按平台盘口撮合([§11](methodology.zh.md#11-定时器与行情延迟同一条时间线))。 挂单可以用 `queue_model={"prob": "power", "n": 2}`(或 `power2`、`power3`、`log`、`log2`)估计无法用成交解释的档位减量中,有多少发生在你的排队位置之前, 这是估计,请与默认的保守排队对比([§12](methodology.zh.md#12-概率排队模型可选))。 `record_ms=60000` 按时间网格记录账户价值(未平仓按最优买价估值)与回撤,见 `Result.record`([§13](methodology.zh.md#13-记录器按时间网格盯市估值))。 `datasets` 里加上 `"spot"` 后可用 `ctx.spot()` 和 `ctx.twap_locked()`,查看结算 TWAP 已累计的时长及当前水平。这是根据 Chainlink 逐秒流计算的近似值,误差实测见 [§14](methodology.zh.md#14-即时价格与结算-twap-已锁定的部分可选)。 ## 限制 - 挂单:排队位置是从聚合档位估算的(数据里没有单个挂单),盘口增量按采集频率抽稀,你的订单不产生市场冲击。挂单只支持 Polymarket: Predict.fun 的归档没有成交流,无法推进排队,因此不接受 Predict.fun 的 `gtc`。独立核对器目前还不重算挂单成交。 - 归档里只有 5 分钟和 15 分钟的加密货币涨跌市场。其他 Polymarket 二元市场用自己的数据回测(见上文);托管回测只跑归档数据。 - 币安参考价流在托管机器上,不在归档里,读它的策略要在托管回测里跑。自带的 CSV 序列(`series`,用 `ctx.ext()` 读) 可以用 `ot run` 或托管回测跑;这个运行器暂不支持自带序列,会直接拒绝运行,不会忽略序列继续回测。 - 单个样本日的结果不能代表下个月。评估回测指标前,先在一段时间范围内验证。 ## outcometick.com 上还有什么 - 数据归档:[Polymarket 与 Predict.fun 加密涨跌市场的逐笔归档](https://outcometick.com/zh):盘口快照与增量、成交、 最优买卖价、市场元数据,以及市场结算所用的 Chainlink 价格流,按天发布。下载某一天后把目录传给 `run()`, 或者用 pandas、Polars、DuckDB 直接读。免费样本在 [GitHub](https://github.com/outcometick/polymarket-tick-data-samples)、 Kaggle 和 Hugging Face 上也有。 - 托管回测:[outcometick.com/zh/backtest](https://outcometick.com/zh/backtest) 在我们的机器上用最近几周的数据跑同一个策略类, 无需下载数据;按扫描的市场日计费,没有产出报告的运行不收费。 - 结算审计:[逐个市场](https://outcometick.com/zh/polymarket-settlement-audit)用归档的 Chainlink 流重算结算结果,并与平台结果对比。 - AI 编程工具支持:npm 包 `outcometick` 带了 MCP 服务(`ot mcp`),Claude Code、Cursor 可以直接查归档、在本地检查策略, 见[文档](https://outcometick.com/zh/docs)。 ## 社区 提问、讨论策略或核对回测结果,可以加入: - Discord(中英文分区):https://discord.gg/ZDMqkptwm5 - Telegram 群:https://t.me/+TcK_hJOzOz5mZTk1 ## 仓库结构 | 路径 | 内容 | | --- | --- | | `outcometick/` | PyPI 包 `outcometick`:SDK、数据客户端(`outcometick.data`)、`outcometick.backtest` | | `tests/` | 包自带的单元测试,含手算答案 | | `examples/` | 可运行的策略;`examples/templates/` 是 `ot init` 附带的模板 | | `notebooks/` | 快速上手 notebook,带运行结果 | | `verify/` | 本包与 JavaScript 实现的逐项对照测试 | | `docs/` | 方法说明与验证方式 | 由 outcometick 主仓库生成,托管回测用的也是同一个引擎;PyPI 发布在 [outcometick-sdk-python](https://github.com/outcometick/outcometick-sdk-python)。欢迎在这里提 issue 和 PR。 许可证:MIT。