Paper-trading harness for Polymarket prediction markets, built around the short crypto "Up or Down" markets (5 and 15 minutes). It records live order books, replays full trade histories, simulates fills with each market's own fee schedule, enforces a risk envelope with a kill switch, journals every decision in a hash-chained log and produces an honest metrics report.
- It never places live orders. There is exactly one executor and it is the paper
one. That is a legal decision for the author's jurisdiction, not a missing feature:
see
docs/decisions/0001-resheniya-do-koda.md. - It does not promise profit. Profitability is a property of a strategy, not of this code. The bundled strategy is a sample of how a strategy plugs in; its reports on real tapes are negative and are shown as such.
- It does not scrape polymarket.com. Only the documented public APIs are used,
rate limits (
429/Retry-After) are respected, and no data is redistributed. - It keeps no personal data. Wallet addresses and transaction hashes present in API responses are dropped before anything is written to disk.
- Python 3.11 or newer (3.11 and 3.14 are tested in CI), or Docker.
- No API key, wallet or account. Public endpoints only.
- Linux and macOS. Windows is not tested and not claimed.
uv sync --all-groups --all-extras
uv run paperfill --helpOr with Docker:
docker build -t paperfill .
docker run --rm -v "$PWD/data:/app/data" paperfill discover --asset BTC --window 5mThe image runs as uid 1000; if your host user differs, pass --user "$(id -u)" so
files written into data/ stay yours. docker compose up with RUN_DIR=<run dir name> serves the dashboard of that run on 127.0.0.1:8765.
| Command | What it does |
|---|---|
paperfill discover --asset BTC --window 5m |
open Up/Down markets, soonest first, with tick, minimum size, fee rate and rebate read from the market itself |
paperfill record --condition 0x… --seconds 300 |
record the market WebSocket stream (book snapshots, level changes, prints) plus the Binance spot, Chainlink spot and Chainlink 60 s TWAP reference prices for the asset, to an append-only file; gaps are recorded, reconnection backs off automatically (--no-prices to skip the price feeds) |
paperfill replay --condition 0x… |
download the full trade history of a market (cursor walk, Retry-After honoured), store it without personal fields, print VWAPs and a deterministic digest |
paperfill run --condition 0x… [--recording file] [--strategy two-sided|fair-value] |
paper-trade a sample strategy over the trade history (replay) or over a recording; writes journal.jsonl, report.md, report.json into data/runs/<id>/ |
paperfill report <run-dir> |
rebuild the report from the journal (fails if the chain is broken) |
paperfill journal verify <file> |
verify the SHA-256 hash chain of a journal |
paperfill kill <run-dir> |
raise the kill switch: the next event halts the run and cancels everything |
paperfill dashboard <run-dir> |
serve a page with run state, summary, positions, journal tail and a kill button (bound to 127.0.0.1 by default) |
paperfill version |
print the installed version |
run --recording still asks the Gamma API for the market's parameters (tick, fees,
outcome); the tape itself is read locally. A recording that does not reach the
market's end time is marked to market at its last mark instead of being settled.
Run parameters: --capital, --size, --max-order, --max-position, --max-market,
--max-exposure, --daily-loss, --total-loss, or a TOML file via --config
(src/paperfill/config.py documents the sections; a flag on the command line wins
over the file). run --resume <run-dir> continues a halted or interrupted run from
its journal after you remove the KILL file: fills and progress are rebuilt from the
journal, open orders are not (they were cancelled on halt).
Research over many windows: collect records consecutive windows for hours,
batch --workers N runs one strategy over every recording in parallel (results are
byte-identical to a sequential run), calibrate scores the fair-value model against
the book mid (Brier, reliability), sweep tries a parameter grid on earlier windows
and reports the chosen combination on later ones. Batch summaries include every window
(halted runs are settled and counted), a bootstrap 95% interval of the mean and a
t-statistic; windows are consecutive, so both understate the uncertainty.
Files carry a schema version (start record of a recording, run_start entry of a
journal); readers refuse newer schemas instead of misreading them. record --max-mb
caps the file size. Logs are JSON lines on stderr (--log-level); the dashboard
answers /healthz.
paperfill pairs scans recordings for moments when the best ask of Up plus the best
ask of Down is below 1 by more than the taker fees on both legs, and reports episodes
with duration floors of 100 ms and 1 s (shorter ones are flicker between the two
tokens' feeds). src/paperfill/venues.py defines the read-only venue interface a
client can implement for data they are licensed to use; Polymarket is the only venue
shipped. A Kalshi adapter is deliberately absent: Kalshi's Developer Agreement limits
its API to a member's own trading (docs/decisions/0004-kalshi.md).
paperfill mcp (extra: uv sync --extra mcp) exposes the same paper-only tools to an
agent over stdio: discover_markets, fetch_history, run_paper, read_report,
verify_journal, list_recordings, list_runs, run_batch, calibrate,
raise_kill_switch, plus a paperfill://runs/{name}/report.md resource. Tools return
structured content with schemas and carry read-only / destructive annotations. For
Claude Code: claude mcp add paperfill -- uv run --directory <repo> paperfill mcp.
Both are samples of how a strategy plugs in, not advice, and both only ever post resting bids (post-only, GTD).
two-sided: bid both outcome tokens at the best bid, sizes leaned by the recent drift of the Up mid.fair-value: bid both tokens when a pair costs at most one dollar, sized by the edge between the book and an external fair value. The fair value follows the market's own resolution rule: "Up" if the Chainlink 60 s TWAP at the end of the window is at or above the price at its start, sop_up = Φ(ln(P_now/P_start) / (σ√τ))withσan exponentially weighted volatility of Binance spot returns andτthe seconds left (src/paperfill/signals.py). It needs a recording that includes the reference prices; without one it does nothing and the journal says so.
The model is deliberately conservative and documented in src/paperfill/execution.py
and docs/decisions/0002-model-ispolneniya.md:
- An order that crosses the book is filled level by level as a taker and pays
the taker fee
C × rate × (p × (1 − p)) ^ exponentfrom the market'sfeeSchedule, rounded to 5 decimals (fees verified against the 83 example values in the official fee tables). - A resting order is filled as a maker (no fee; rebate reported separately as an
upper-bound estimate) only when the market trades through its price: a print
strictly better than the order price in replay, or the opposite side of the book
crossing it on a recording, once per crossing. Queue model (
[model] queue_model, default on): a resting order queues behind the size already at its price when it is placed; prints at that price and book crossings consume the queue first and only the remainder reaches the order; a level that shrinks below the queue caps it. On a tape without a book,assumed_queue_aheadstands in. With the model off, prints exactly at the order price never fill. price_changemessages are applied atomically: a message may carry several level changes, and the book is only evaluated between messages, never between the changes of one message (states that never existed on the exchange).- Positions are held long-only and settled at the market's resolution payouts (1 or 0 per share) when the market is resolved. Buy fees are part of the cost basis, so "realized P&L after fees" is after all fees on both legs.
- No latency or queue model: the harness measures a strategy's logic against a tape, not its speed.
Level semantics of the price_change stream were verified empirically: 400 of 402
book snapshots in a live recording matched the book rebuilt from the preceding
changes (docs/measurements/2026-09-19-price-change-semantics.md).
Per-order notional, per-token position, per-market cost, total exposure, daily and total loss breakers (measured on equity at mid), pause, and a file-based kill switch that a dashboard or an operator can raise from outside the process. Every block and every halt is written to the journal with its reason.
Every order, fill, rejection, risk decision and lifecycle event is a journal entry
linked by a SHA-256 hash chain; journal verify reports the first edited or deleted
line. The chain is unkeyed: it catches edits, not a full rewrite, and a truncated tail
is only visible through the missing run_end entry. Publish the head hash somewhere
else if you need more than that. The
report is computed from the journal only, so it can be regenerated later and never
says anything that was not recorded: orders, fills, fill rate, both-sides
participation, fees, rebates, realized P&L after fees, max drawdown, per-token and
per-lean-bucket breakdowns with win rates once settled.
uv run ruff check . && uv run ruff format --check .
uv run pytest --cov -m "not live" # offline suite, runs in CI
uv run pytest -m live # talks to the public API
make sources-check # warns about stale reference sourcesEnglish design notes for clients: docs/en/design.md. Sources of truth for every
claim about Polymarket behaviour are listed in SOURCES.md. A claim without a source is marked as unverified. Decisions live in
docs/decisions/, requirements in docs/zadanie.md, measurements in
docs/measurements/.
MIT, see LICENSE.